@saadnvd1/agent-os 0.3.836 → 0.3.839

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 (54) hide show
  1. package/app/api/fresh-start/route.test.ts +33 -0
  2. package/app/api/fresh-start/route.ts +3 -0
  3. package/components/Devices/DeviceRow.tsx +83 -69
  4. package/components/Devices/PasskeysSection.test.ts +89 -0
  5. package/components/Devices/PasskeysSection.tsx +36 -26
  6. package/components/Health/HealthPanel.tsx +41 -16
  7. package/components/Merge/GlobalMergeSection.tsx +2 -5
  8. package/components/Merge/MergeSettingsFields.tsx +39 -77
  9. package/components/Merge/ProjectMergeSection.tsx +2 -6
  10. package/components/Merge/WorktreeSweepSection.tsx +40 -14
  11. package/components/Models/AgentRoleCard.tsx +61 -66
  12. package/components/Models/AgentRolesSection.tsx +5 -8
  13. package/components/Models/ModelsSection.tsx +7 -18
  14. package/components/Models/PriceInput.tsx +59 -0
  15. package/components/PhoneNotify/PhoneNotifyForm.tsx +37 -58
  16. package/components/Settings/AppWideLimits.tsx +61 -0
  17. package/components/Settings/ContextSection.tsx +96 -0
  18. package/components/Settings/ExperimentsSection.test.ts +9 -7
  19. package/components/Settings/ExperimentsSection.tsx +22 -23
  20. package/components/Settings/InlineConfirm.tsx +51 -0
  21. package/components/Settings/OwnerNameRow.tsx +10 -40
  22. package/components/Settings/PickerSetting.tsx +59 -0
  23. package/components/Settings/SettingRow.tsx +131 -0
  24. package/components/Settings/SettingsDialog.test.ts +1 -0
  25. package/components/Settings/SettingsDialog.tsx +47 -2
  26. package/components/Settings/settings-fields.test.ts +103 -0
  27. package/components/Settings/settings-fields.tsx +427 -0
  28. package/components/Shares/SharedLinksPanel.tsx +62 -53
  29. package/components/Workspaces/HqFolderForm.tsx +21 -54
  30. package/components/Workspaces/McpServersForm.tsx +20 -19
  31. package/components/Workspaces/ProjectTiersForm.tsx +10 -8
  32. package/components/Workspaces/RulesForm.test.ts +127 -0
  33. package/components/Workspaces/RulesForm.tsx +54 -126
  34. package/components/Workspaces/WorkspaceModelsForm.tsx +27 -26
  35. package/components/Workspaces/WorkspaceSettings.tsx +61 -24
  36. package/content/whats-new.json +16 -0
  37. package/data/fresh-start/index.ts +1 -1
  38. package/data/workspaces/index.ts +9 -0
  39. package/hooks/useAutoSave.test.ts +116 -0
  40. package/hooks/useAutoSave.ts +98 -0
  41. package/lib/away/window-limits.ts +3 -1
  42. package/lib/chat/refresh.ts +10 -6
  43. package/lib/chat/stall-limits.ts +3 -1
  44. package/lib/settings/defaults.ts +27 -0
  45. package/lib/settings/parse.ts +17 -0
  46. package/lib/settings-url.ts +1 -0
  47. package/lib/worktree-sweep/limits.ts +3 -0
  48. package/lib/worktree-sweep/settings.ts +4 -2
  49. package/package.json +1 -1
  50. package/components/Away/AwayWindowForm.tsx +0 -91
  51. package/components/Workspaces/FreshStartForm.tsx +0 -177
  52. package/components/Workspaces/StallForm.tsx +0 -101
  53. package/components/Workspaces/TaskLimitForm.tsx +0 -84
  54. package/components/Workspaces/fresh-start.ts +0 -19
@@ -24,7 +24,7 @@ export function useUpdateFreshStart() {
24
24
  mutationFn: async (settings: Partial<RefreshSettings>) =>
25
25
  json<RefreshSettings>(
26
26
  await fetch("/api/fresh-start", {
27
- method: "PUT",
27
+ method: "PATCH",
28
28
  headers: { "Content-Type": "application/json" },
29
29
  body: JSON.stringify(settings),
30
30
  })
@@ -87,6 +87,9 @@ export function useUpdateWorkspace() {
87
87
  ...updates
88
88
  }) => {
89
89
  await queryClient.cancelQueries({ queryKey: workspaceKeys.list() });
90
+ const previous = queryClient.getQueryData<Workspace[]>(
91
+ workspaceKeys.list()
92
+ );
90
93
  queryClient.setQueryData<Workspace[]>(workspaceKeys.list(), (prev) =>
91
94
  prev?.map((w) =>
92
95
  w.id === id
@@ -109,6 +112,12 @@ export function useUpdateWorkspace() {
109
112
  : w
110
113
  )
111
114
  );
115
+ return { previous };
116
+ },
117
+ // A rejected change rolls back to what the server holds.
118
+ onError: (_err, _vars, context) => {
119
+ if (context?.previous)
120
+ queryClient.setQueryData(workspaceKeys.list(), context.previous);
112
121
  },
113
122
  onSettled: () =>
114
123
  queryClient.invalidateQueries({ queryKey: workspaceKeys.all }),
@@ -0,0 +1,116 @@
1
+ import "@/components/Chat/composer/test-dom";
2
+ import { act, createElement } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { useAutoSave, type AutoSave } from "./useAutoSave";
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
+ afterEach(async () => {
14
+ await act(async () => root.unmount());
15
+ host.remove();
16
+ vi.useRealTimers();
17
+ });
18
+
19
+ // A server whose value the hook is given back as `saved`, like a query.
20
+ async function mount(
21
+ save: (n: number) => Promise<unknown>,
22
+ notify = vi.fn(),
23
+ initial = 10
24
+ ) {
25
+ const box: { auto: AutoSave<number> } = {} as never;
26
+ let saved = initial;
27
+ const Probe = ({ value }: { value: number }) => {
28
+ box.auto = useAutoSave({
29
+ saved: value,
30
+ save: async (n) => {
31
+ await save(n);
32
+ saved = n;
33
+ },
34
+ notify,
35
+ });
36
+ return null;
37
+ };
38
+ host = document.createElement("div");
39
+ document.body.appendChild(host);
40
+ root = createRoot(host);
41
+ const render = () =>
42
+ act(async () => root.render(createElement(Probe, { value: saved })));
43
+ await render();
44
+ return { box, render, notify };
45
+ }
46
+
47
+ describe("useAutoSave", () => {
48
+ it("saves a new value and says Saved, then goes quiet", async () => {
49
+ vi.useFakeTimers();
50
+ const save = vi.fn().mockResolvedValue(undefined);
51
+ const { box, render } = await mount(save);
52
+ await act(async () => void (await box.auto.commit(20)));
53
+ await render();
54
+ expect(save).toHaveBeenCalledWith(20);
55
+ expect(box.auto.value).toBe(20);
56
+ expect(box.auto.status).toBe("saved");
57
+ await act(async () => void vi.advanceTimersByTime(2000));
58
+ expect(box.auto.status).toBe("idle");
59
+ });
60
+
61
+ it("sends nothing for the value it already has", async () => {
62
+ const save = vi.fn().mockResolvedValue(undefined);
63
+ const { box } = await mount(save);
64
+ await act(async () => void (await box.auto.commit(10)));
65
+ expect(save).not.toHaveBeenCalled();
66
+ expect(box.auto.status).toBe("idle");
67
+ });
68
+
69
+ it("shows the value while saving", async () => {
70
+ let finish!: () => void;
71
+ const save = vi.fn(() => new Promise<void>((r) => (finish = r)));
72
+ const { box } = await mount(save);
73
+ let done!: Promise<boolean>;
74
+ await act(async () => void (done = box.auto.commit(20)));
75
+ expect(box.auto.status).toBe("saving");
76
+ expect(box.auto.value).toBe(20);
77
+ await act(async () => {
78
+ finish();
79
+ await done;
80
+ });
81
+ });
82
+
83
+ it("rolls back to the saved value and tells the user when the server rejects", async () => {
84
+ const save = vi.fn().mockRejectedValue(new Error("Must be 50 or more"));
85
+ const { box, notify } = await mount(save);
86
+ let ok = true;
87
+ await act(async () => void (ok = await box.auto.commit(20)));
88
+ expect(ok).toBe(false);
89
+ expect(box.auto.value).toBe(10);
90
+ expect(box.auto.status).toBe("idle");
91
+ expect(notify).toHaveBeenCalledWith("Must be 50 or more");
92
+ });
93
+
94
+ it("undoes an undoable change by saving the old value back", async () => {
95
+ const save = vi.fn().mockResolvedValue(undefined);
96
+ const { box, render } = await mount(save);
97
+ await act(async () => void (await box.auto.commit(0, { undoable: true })));
98
+ await render();
99
+ expect(box.auto.status).toBe("undo");
100
+ await act(async () => void (await box.auto.undo()));
101
+ await render();
102
+ expect(save).toHaveBeenLastCalledWith(10);
103
+ expect(box.auto.value).toBe(10);
104
+ expect(box.auto.status).toBe("saved");
105
+ });
106
+
107
+ it("has nothing to undo after a plain change", async () => {
108
+ const save = vi.fn().mockResolvedValue(undefined);
109
+ const { box } = await mount(save);
110
+ await act(async () => void (await box.auto.commit(20)));
111
+ let ok = true;
112
+ await act(async () => void (ok = await box.auto.undo()));
113
+ expect(ok).toBe(false);
114
+ expect(save).toHaveBeenCalledTimes(1);
115
+ });
116
+ });
@@ -0,0 +1,98 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { toast } from "sonner";
5
+
6
+ export type SaveStatus = "idle" | "saving" | "saved" | "undo";
7
+
8
+ const SAVED_MS = 1800;
9
+ const UNDO_MS = 7000;
10
+
11
+ export interface AutoSave<T> {
12
+ // What to show: the value in flight while saving, else the saved one. A
13
+ // rejected save drops the in-flight value, so this is the saved one again.
14
+ value: T;
15
+ status: SaveStatus;
16
+ // Commit a new value. `undoable` keeps the previous one for Undo (large or
17
+ // destructive changes). Resolves false when the server rejected it.
18
+ commit: (next: T, opts?: { undoable?: boolean }) => Promise<boolean>;
19
+ undo: () => Promise<boolean>;
20
+ }
21
+
22
+ /**
23
+ * Save a setting the moment it changes. `saved` is the server's value
24
+ * (the query's); `save` sends one value and throws to reject it.
25
+ */
26
+ export function useAutoSave<T>({
27
+ saved,
28
+ save,
29
+ equals = Object.is,
30
+ notify = (message) => toast.error(message),
31
+ }: {
32
+ saved: T;
33
+ save: (next: T) => Promise<unknown>;
34
+ equals?: (a: T, b: T) => boolean;
35
+ notify?: (message: string) => void;
36
+ }): AutoSave<T> {
37
+ const [pending, setPending] = useState<{ value: T } | null>(null);
38
+ const [status, setStatus] = useState<SaveStatus>("idle");
39
+ const previous = useRef<{ value: T } | null>(null);
40
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
41
+ const seq = useRef(0);
42
+ const latest = useRef({ saved, save, equals, notify });
43
+ useEffect(() => {
44
+ latest.current = { saved, save, equals, notify };
45
+ });
46
+ useEffect(() => () => clearTimeout(timer.current), []);
47
+
48
+ const settle = useCallback((next: SaveStatus, ms: number) => {
49
+ setStatus(next);
50
+ clearTimeout(timer.current);
51
+ timer.current = setTimeout(() => setStatus("idle"), ms);
52
+ }, []);
53
+
54
+ const run = useCallback(
55
+ async (next: T, undoable: boolean, before: T) => {
56
+ const mine = ++seq.current;
57
+ clearTimeout(timer.current);
58
+ setPending({ value: next });
59
+ setStatus("saving");
60
+ try {
61
+ await latest.current.save(next);
62
+ } catch (err) {
63
+ if (mine === seq.current) {
64
+ setPending(null);
65
+ setStatus("idle");
66
+ }
67
+ latest.current.notify(
68
+ err instanceof Error ? err.message : "Couldn't save"
69
+ );
70
+ return false;
71
+ }
72
+ if (mine !== seq.current) return true;
73
+ setPending(null);
74
+ previous.current = undoable ? { value: before } : null;
75
+ settle(undoable ? "undo" : "saved", undoable ? UNDO_MS : SAVED_MS);
76
+ return true;
77
+ },
78
+ [settle]
79
+ );
80
+
81
+ const commit = useCallback(
82
+ (next: T, opts?: { undoable?: boolean }) => {
83
+ const { saved: current, equals: same } = latest.current;
84
+ if (same(next, current)) return Promise.resolve(true);
85
+ return run(next, !!opts?.undoable, current);
86
+ },
87
+ [run]
88
+ );
89
+
90
+ const undo = useCallback(async () => {
91
+ const back = previous.current;
92
+ if (!back) return false;
93
+ previous.current = null;
94
+ return run(back.value, false, latest.current.saved);
95
+ }, [run]);
96
+
97
+ return { value: pending ? pending.value : saved, status, commit, undo };
98
+ }
@@ -1,6 +1,8 @@
1
+ import { SETTING_DEFAULTS } from "../settings/defaults";
2
+
1
3
  // The Away window's bounds, in minutes, for the server (./activity) and
2
4
  // the Settings form alike. Client-safe: no database.
3
- export const DEFAULT_WINDOW_MINUTES = 60;
5
+ export const DEFAULT_WINDOW_MINUTES = SETTING_DEFAULTS.awayWindowMinutes;
4
6
  export const MIN_WINDOW_MINUTES = 10;
5
7
  export const MAX_WINDOW_MINUTES = 24 * 60;
6
8
 
@@ -6,7 +6,7 @@
6
6
  * default: its state is in the database and its brief carries it
7
7
  * (lib/orchestrator/digest). A task keeps its progress only in its
8
8
  * conversation and its branch, so for tasks it's off unless switched on.
9
- * App-wide, in Settings → Workspaces, with the two sizes at which chats and
9
+ * App-wide, in Settings → Context, with the two sizes at which chats and
10
10
  * tasks compact: mid-turn (Claude Code itself, high so a job keeps its
11
11
  * files) and between turns (lib/chat/stale, earlier, nothing in flight).
12
12
  */
@@ -15,24 +15,28 @@ import { db } from "../db";
15
15
  import { effectiveModels } from "../models/config";
16
16
  import { type ModelRole } from "../models/roles";
17
17
  import { sessionRole } from "../models/session-role";
18
+ import { SETTING_DEFAULTS } from "../settings/defaults";
18
19
  import { MAX_REFRESH_TOKENS, MIN_REFRESH_TOKENS } from "./refresh-limits";
19
20
 
20
21
  export const ORCHESTRATOR_KEY = "orchestrator.refresh_tokens";
21
22
  export const TASK_KEY = "chat.task_refresh_tokens";
22
23
  // Named for what it first did: compact chats idle past their cache.
23
24
  export const COMPACT_KEY = "chat.stale_compact_tokens";
24
- export const DEFAULT_ORCHESTRATOR_TOKENS = 200_000;
25
- export const DEFAULT_COMPACT_TOKENS = 150_000;
25
+ export const DEFAULT_ORCHESTRATOR_TOKENS =
26
+ SETTING_DEFAULTS.fresh.orchestratorTokens;
27
+ export const DEFAULT_COMPACT_TOKENS = SETTING_DEFAULTS.fresh.compactTokens;
26
28
  // Claude Code's own mid-turn auto-compact window: high, because a summary
27
29
  // mid-job costs the agent its files and its place.
28
30
  export const AUTO_COMPACT_KEY = "chat.auto_compact_tokens";
29
- export const DEFAULT_AUTO_COMPACT_TOKENS = 300_000;
31
+ export const DEFAULT_AUTO_COMPACT_TOKENS =
32
+ SETTING_DEFAULTS.fresh.autoCompactTokens;
30
33
  // The same window for a person's own (interactive) sessions, which run long
31
34
  // on the biggest model. 300k (DEFAULT_AUTO_COMPACT_TOKENS) was its value
32
35
  // before it had its own setting: set it back to restore that.
33
36
  export const INTERACTIVE_AUTO_COMPACT_KEY =
34
37
  "chat.interactive_auto_compact_tokens";
35
- export const DEFAULT_INTERACTIVE_AUTO_COMPACT_TOKENS = 300_000;
38
+ export const DEFAULT_INTERACTIVE_AUTO_COMPACT_TOKENS =
39
+ SETTING_DEFAULTS.fresh.interactiveAutoCompactTokens;
36
40
  export { MIN_REFRESH_TOKENS, MAX_REFRESH_TOKENS } from "./refresh-limits";
37
41
 
38
42
  export interface RefreshSettings {
@@ -60,7 +64,7 @@ function read(key: string, fallback: number): number {
60
64
  export function refreshSettings(): RefreshSettings {
61
65
  return {
62
66
  orchestratorTokens: read(ORCHESTRATOR_KEY, DEFAULT_ORCHESTRATOR_TOKENS),
63
- taskTokens: read(TASK_KEY, 0),
67
+ taskTokens: read(TASK_KEY, SETTING_DEFAULTS.fresh.taskTokens),
64
68
  compactTokens: read(COMPACT_KEY, DEFAULT_COMPACT_TOKENS),
65
69
  autoCompactTokens: read(AUTO_COMPACT_KEY, DEFAULT_AUTO_COMPACT_TOKENS),
66
70
  interactiveAutoCompactTokens: read(
@@ -1,5 +1,7 @@
1
+ import { SETTING_DEFAULTS } from "../settings/defaults";
2
+
1
3
  // The range the stall alert may take (lib/chat/stall), shared with the
2
4
  // settings form.
3
- export const DEFAULT_STALL_MINUTES = 8;
5
+ export const DEFAULT_STALL_MINUTES = SETTING_DEFAULTS.stallMinutes;
4
6
  export const MIN_STALL_MINUTES = 2;
5
7
  export const MAX_STALL_MINUTES = 120;
@@ -0,0 +1,27 @@
1
+ // Every setting's default, in one pure module the server's constants and the
2
+ // settings UI both import, so the two can't disagree (lib/settings/defaults.test).
3
+ // Context sizes are in tokens; 0 is off.
4
+
5
+ export const SETTING_DEFAULTS = {
6
+ fresh: {
7
+ orchestratorTokens: 200_000,
8
+ taskTokens: 0,
9
+ compactTokens: 150_000,
10
+ autoCompactTokens: 300_000,
11
+ interactiveAutoCompactTokens: 300_000,
12
+ },
13
+ // What a switch turns on with when the default is off.
14
+ freshOnValue: 200_000,
15
+ stallMinutes: 8,
16
+ awayWindowMinutes: 60,
17
+ // Off by default; what the switch turns on with.
18
+ taskLimitOn: 3,
19
+ sweepDays: 7,
20
+ } as const;
21
+
22
+ export type FreshKey = keyof typeof SETTING_DEFAULTS.fresh;
23
+
24
+ export function freshOnValue(key: FreshKey): number {
25
+ const d = SETTING_DEFAULTS.fresh[key];
26
+ return d > 0 ? d : SETTING_DEFAULTS.freshOnValue;
27
+ }
@@ -0,0 +1,17 @@
1
+ // A typed number, checked before anything is sent. An invalid one stays in
2
+ // the field with this message; nothing reaches the server.
3
+ export function parseSettingNumber(
4
+ text: string,
5
+ { min, max, integer = true }: { min: number; max?: number; integer?: boolean }
6
+ ): { value: number } | { error: string } {
7
+ const t = text.trim();
8
+ const n = Number(t);
9
+ const range = max === undefined ? `${min} or more` : `from ${min} to ${max}`;
10
+ const msg = integer
11
+ ? `Enter a whole number ${range}.`
12
+ : `Enter a number ${range}.`;
13
+ if (!t || !Number.isFinite(n)) return { error: msg };
14
+ if (integer && !Number.isInteger(n)) return { error: msg };
15
+ if (n < min || (max !== undefined && n > max)) return { error: msg };
16
+ return { value: n };
17
+ }
@@ -8,6 +8,7 @@ export const SETTINGS_SECTIONS = [
8
8
  "merging",
9
9
  "approvals",
10
10
  "workspaces",
11
+ "context",
11
12
  "models",
12
13
  "agent-roles",
13
14
  "devices",
@@ -0,0 +1,3 @@
1
+ // The sweep's day range, client-safe: the server (./settings) and the
2
+ // Settings box share it.
3
+ export const MAX_SWEEP_DAYS = 365;
@@ -5,10 +5,12 @@
5
5
 
6
6
  import { z } from "zod";
7
7
  import { db } from "../db";
8
+ import { SETTING_DEFAULTS } from "../settings/defaults";
9
+ import { MAX_SWEEP_DAYS } from "./limits";
8
10
  import type { ResolvedDays } from "./types";
9
11
 
10
- export const DEFAULT_SWEEP_DAYS = 7;
11
- export const MAX_SWEEP_DAYS = 365;
12
+ export const DEFAULT_SWEEP_DAYS = SETTING_DEFAULTS.sweepDays;
13
+ export { MAX_SWEEP_DAYS } from "./limits";
12
14
 
13
15
  const GLOBAL_KEY = "worktree_sweep";
14
16
  const projectKey = (projectId: string) => `worktree_sweep.project.${projectId}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.836",
3
+ "version": "0.3.839",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -1,91 +0,0 @@
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 {
8
- useAwaySettingsQuery,
9
- useUpdateAwaySettings,
10
- type AwaySettings,
11
- } from "@/data/away";
12
- import {
13
- MAX_WINDOW_MINUTES,
14
- MIN_WINDOW_MINUTES,
15
- parseWindow,
16
- } from "@/lib/away/window-limits";
17
-
18
- // Settings → Workspaces: how recent a session's work must be for Away mode
19
- // to touch it. App-wide.
20
- export function AwayWindowForm() {
21
- const { data, isPending, isError, error } = useAwaySettingsQuery();
22
- if (isPending)
23
- return <div className="bg-muted/40 h-24 animate-pulse rounded-lg" />;
24
- if (isError)
25
- return <p className="text-destructive text-sm">{error.message}</p>;
26
- return <Form key={data.windowMinutes} saved={data} />;
27
- }
28
-
29
- function Form({ saved }: { saved: AwaySettings }) {
30
- const update = useUpdateAwaySettings();
31
- const [text, setText] = useState(String(saved.windowMinutes));
32
- const minutes = parseWindow(text);
33
- const error =
34
- minutes === null
35
- ? `A whole number from ${MIN_WINDOW_MINUTES} to ${MAX_WINDOW_MINUTES}`
36
- : null;
37
- return (
38
- <form
39
- onSubmit={(e) => {
40
- e.preventDefault();
41
- if (minutes === null) return;
42
- update.mutate(
43
- { windowMinutes: minutes },
44
- {
45
- onSuccess: () => toast.success("Away mode settings saved"),
46
- onError: (err) => toast.error(err.message),
47
- }
48
- );
49
- }}
50
- className="bg-muted/40 space-y-3 rounded-lg px-3 py-3"
51
- >
52
- <h3 className="text-sm font-medium">Away mode</h3>
53
- <p className="text-muted-foreground text-sm">
54
- Away mode works through orchestrators that were busy in this window, and
55
- only lists sessions with a real turn inside it. Older ones are left
56
- alone: a message wakes an agent and re-reads its whole conversation.
57
- </p>
58
- <label className="flex items-center justify-between gap-3 text-sm">
59
- <span className="text-muted-foreground">Recent means the last</span>
60
- <span className="flex items-center gap-2">
61
- <Input
62
- type="number"
63
- inputMode="numeric"
64
- min={MIN_WINDOW_MINUTES}
65
- max={MAX_WINDOW_MINUTES}
66
- step={1}
67
- value={text}
68
- onChange={(e) => setText(e.target.value)}
69
- aria-label="Away window, minutes"
70
- aria-invalid={!!error}
71
- className="h-11 w-24 text-base sm:h-9 md:text-sm"
72
- />
73
- <span className="text-muted-foreground">min</span>
74
- </span>
75
- </label>
76
- {error && <p className="text-destructive text-xs">{error}</p>}
77
- <div className="flex justify-end">
78
- <Button
79
- type="submit"
80
- size="sm"
81
- className="h-11 sm:h-8"
82
- disabled={
83
- !!error || minutes === saved.windowMinutes || update.isPending
84
- }
85
- >
86
- Save
87
- </Button>
88
- </div>
89
- </form>
90
- );
91
- }
@@ -1,177 +0,0 @@
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
22
- key={`${data.orchestratorTokens}:${data.taskTokens}:${data.compactTokens}:${data.autoCompactTokens}:${data.interactiveAutoCompactTokens}`}
23
- saved={data}
24
- />
25
- );
26
- }
27
-
28
- function Form({ saved }: { saved: RefreshSettings }) {
29
- const update = useUpdateFreshStart();
30
- const orchestrator = useThreshold(saved.orchestratorTokens, 200);
31
- const task = useThreshold(saved.taskTokens, 200);
32
- const compact = useThreshold(saved.compactTokens, 150);
33
- const midTurn = useThreshold(saved.autoCompactTokens, 300);
34
- const interactive = useThreshold(saved.interactiveAutoCompactTokens, 200);
35
- const error =
36
- orchestrator.error ??
37
- task.error ??
38
- compact.error ??
39
- midTurn.error ??
40
- interactive.error;
41
- const dirty =
42
- !error &&
43
- (orchestrator.tokens !== saved.orchestratorTokens ||
44
- task.tokens !== saved.taskTokens ||
45
- compact.tokens !== saved.compactTokens ||
46
- midTurn.tokens !== saved.autoCompactTokens ||
47
- interactive.tokens !== saved.interactiveAutoCompactTokens);
48
-
49
- return (
50
- <form
51
- onSubmit={(e) => {
52
- e.preventDefault();
53
- if (error) return;
54
- update.mutate(
55
- {
56
- orchestratorTokens: orchestrator.tokens,
57
- taskTokens: task.tokens,
58
- compactTokens: compact.tokens,
59
- autoCompactTokens: midTurn.tokens,
60
- interactiveAutoCompactTokens: interactive.tokens,
61
- },
62
- {
63
- onSuccess: () => toast.success("Context settings saved"),
64
- onError: (err) => toast.error(err.message),
65
- }
66
- );
67
- }}
68
- className="bg-muted/40 space-y-3 rounded-lg px-3 py-3"
69
- >
70
- <h3 className="text-sm font-medium">Fresh starts</h3>
71
- <p className="text-muted-foreground text-sm">
72
- When a conversation&apos;s context passes the threshold, its agent
73
- starts a new one at the next turn boundary, with its brief. An
74
- orchestrator also gets its rules and the workspace&apos;s current state,
75
- so nothing is lost. A task keeps only its brief and its branch.
76
- </p>
77
- <ThresholdRow label="Orchestrators" field={orchestrator} />
78
- <ThresholdRow label="Tasks" field={task} />
79
- <h3 className="pt-2 text-sm font-medium">Compact</h3>
80
- <p className="text-muted-foreground text-sm">
81
- Chats and tasks compact twice over. Mid-turn, Claude Code summarizes its
82
- own context when a job runs long: set this high, since it happens while
83
- the agent is holding its files. Between turns, nothing is in flight, so
84
- AgentOS compacts a chat over the smaller size, or idle past its prompt
85
- cache, before the next message; the composer offers to first. Either way
86
- AgentOS hands the agent its task, scope changes, files and todos back.
87
- Your own sessions, in a chat or a terminal, have their own mid-turn
88
- size; a terminal can&apos;t be compacted between turns, so that is the
89
- only compaction it gets. Orchestrators start fresh instead.
90
- </p>
91
- <ThresholdRow
92
- label="Mid-turn, tasks"
93
- field={midTurn}
94
- switchLabel="Let a task's agent compact itself mid-turn"
95
- />
96
- <ThresholdRow
97
- label="Mid-turn, your sessions"
98
- field={interactive}
99
- switchLabel="Let your own sessions compact themselves mid-turn"
100
- />
101
- <ThresholdRow
102
- label="Between turns"
103
- field={compact}
104
- switchLabel="Compact chats and tasks between turns"
105
- />
106
- {error && <p className="text-destructive text-xs">{error}</p>}
107
- <div className="flex justify-end">
108
- <Button
109
- type="submit"
110
- size="sm"
111
- className="h-11 sm:h-8"
112
- disabled={!!error || !dirty || update.isPending}
113
- >
114
- Save
115
- </Button>
116
- </div>
117
- </form>
118
- );
119
- }
120
-
121
- function useThreshold(tokens: number, fallbackK: number) {
122
- const [on, setOn] = useState(tokens > 0);
123
- const [text, setText] = useState(
124
- String(tokens > 0 ? tokens / 1000 : fallbackK)
125
- );
126
- const parsed = parseFreshStart(on, text);
127
- return {
128
- on,
129
- setOn,
130
- text,
131
- setText,
132
- tokens: "tokens" in parsed ? parsed.tokens : tokens,
133
- error: "error" in parsed ? parsed.error : null,
134
- };
135
- }
136
-
137
- function ThresholdRow({
138
- label,
139
- field,
140
- switchLabel,
141
- }: {
142
- label: string;
143
- field: ReturnType<typeof useThreshold>;
144
- switchLabel?: string;
145
- }) {
146
- return (
147
- <div className="space-y-2">
148
- <label className="flex min-h-11 items-center justify-between gap-3 text-sm md:min-h-9">
149
- {label}
150
- <Switch
151
- checked={field.on}
152
- onCheckedChange={field.setOn}
153
- aria-label={switchLabel ?? `Fresh starts for ${label.toLowerCase()}`}
154
- />
155
- </label>
156
- {field.on && (
157
- <label className="flex items-center justify-between gap-3 text-sm">
158
- <span className="text-muted-foreground">At</span>
159
- <span className="flex items-center gap-2">
160
- <Input
161
- type="number"
162
- inputMode="numeric"
163
- min={50}
164
- step={10}
165
- value={field.text}
166
- onChange={(e) => field.setText(e.target.value)}
167
- aria-label={`${label} threshold, thousands of tokens`}
168
- aria-invalid={!!field.error}
169
- className="h-11 w-24 text-base sm:h-9 md:text-sm"
170
- />
171
- <span className="text-muted-foreground">k tokens</span>
172
- </span>
173
- </label>
174
- )}
175
- </div>
176
- );
177
- }