@saadnvd1/agent-os 0.3.836 → 0.3.845

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 (81) 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/app/api/health/route.ts +4 -2
  4. package/components/Devices/DeviceRow.tsx +83 -69
  5. package/components/Devices/PasskeysSection.test.ts +89 -0
  6. package/components/Devices/PasskeysSection.tsx +36 -26
  7. package/components/Health/HealthPanel.tsx +41 -16
  8. package/components/Merge/GlobalMergeSection.tsx +2 -5
  9. package/components/Merge/MergeSettingsFields.tsx +39 -77
  10. package/components/Merge/ProjectMergeSection.tsx +2 -6
  11. package/components/Merge/WorktreeSweepSection.tsx +40 -14
  12. package/components/Models/AgentRoleCard.tsx +61 -66
  13. package/components/Models/AgentRolesSection.tsx +5 -8
  14. package/components/Models/ModelsSection.tsx +7 -18
  15. package/components/Models/PriceInput.tsx +59 -0
  16. package/components/PhoneNotify/PhoneNotifyForm.tsx +37 -58
  17. package/components/SessionList/GroupShelves.tsx +8 -3
  18. package/components/SessionList/OrchestratorPinRow.tsx +2 -2
  19. package/components/SessionList/RowParts.tsx +39 -35
  20. package/components/SessionList/RowRename.test.ts +86 -17
  21. package/components/SessionList/RowShell.tsx +2 -5
  22. package/components/SessionList/SessionRow.tsx +0 -1
  23. package/components/Settings/AppWideLimits.tsx +61 -0
  24. package/components/Settings/ContextSection.tsx +96 -0
  25. package/components/Settings/ExperimentsSection.test.ts +9 -7
  26. package/components/Settings/ExperimentsSection.tsx +22 -23
  27. package/components/Settings/InlineConfirm.tsx +51 -0
  28. package/components/Settings/OwnerNameRow.tsx +10 -40
  29. package/components/Settings/PickerSetting.tsx +59 -0
  30. package/components/Settings/SettingRow.tsx +168 -0
  31. package/components/Settings/SettingsDialog.test.ts +1 -0
  32. package/components/Settings/SettingsDialog.tsx +47 -2
  33. package/components/Settings/settings-fields.test.ts +103 -0
  34. package/components/Settings/settings-fields.tsx +428 -0
  35. package/components/Shares/SharedLinksPanel.tsx +62 -53
  36. package/components/Workspaces/HqFolderForm.tsx +22 -53
  37. package/components/Workspaces/McpServersForm.tsx +21 -28
  38. package/components/Workspaces/ProjectTiersForm.tsx +28 -13
  39. package/components/Workspaces/RuleEditor.tsx +86 -0
  40. package/components/Workspaces/RulesForm.test.ts +154 -0
  41. package/components/Workspaces/RulesForm.tsx +116 -117
  42. package/components/Workspaces/WorkspaceModeForm.tsx +3 -3
  43. package/components/Workspaces/WorkspaceModelsForm.tsx +36 -36
  44. package/components/Workspaces/WorkspacePanel.tsx +73 -0
  45. package/components/Workspaces/WorkspacePicker.tsx +44 -0
  46. package/components/Workspaces/WorkspaceSettings.test.ts +153 -0
  47. package/components/Workspaces/WorkspaceSettings.tsx +63 -52
  48. package/components/Workspaces/useSettingsWorkspace.ts +47 -0
  49. package/content/whats-new.json +40 -0
  50. package/data/fresh-start/index.ts +1 -1
  51. package/data/workspaces/index.ts +9 -0
  52. package/hooks/useAutoSave.test.ts +116 -0
  53. package/hooks/useAutoSave.ts +98 -0
  54. package/lib/away/deploy.ts +34 -7
  55. package/lib/away/window-limits.ts +3 -1
  56. package/lib/chat/refresh.ts +10 -6
  57. package/lib/chat/stall-limits.ts +3 -1
  58. package/lib/models/plan-fresh.ts +61 -0
  59. package/lib/models/plan-step.ts +40 -24
  60. package/lib/orchestrator/scope.ts +10 -0
  61. package/lib/settings/defaults.ts +27 -0
  62. package/lib/settings/parse.ts +17 -0
  63. package/lib/settings-url.ts +1 -0
  64. package/lib/updates/banner.ts +2 -0
  65. package/lib/updates/run.ts +9 -0
  66. package/lib/updates/view.ts +28 -6
  67. package/lib/worktree-deps/paths.ts +9 -4
  68. package/lib/worktree-sweep/limits.ts +3 -0
  69. package/lib/worktree-sweep/settings.ts +4 -2
  70. package/lib/worktrees.ts +3 -0
  71. package/package.json +1 -1
  72. package/scripts/autodeploy +11 -0
  73. package/scripts/autodeploy.test.ts +30 -0
  74. package/scripts/lib/update-verify.mjs +11 -0
  75. package/scripts/update-run.mjs +29 -0
  76. package/scripts/update-run.test.ts +62 -4
  77. package/components/Away/AwayWindowForm.tsx +0 -91
  78. package/components/Workspaces/FreshStartForm.tsx +0 -177
  79. package/components/Workspaces/StallForm.tsx +0 -101
  80. package/components/Workspaces/TaskLimitForm.tsx +0 -84
  81. package/components/Workspaces/fresh-start.ts +0 -19
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
4
3
  import { Button } from "@/components/ui/button";
5
- import { Input } from "@/components/ui/input";
4
+ import { SettingSection } from "@/components/Settings/SettingRow";
5
+ import { TextSetting } from "@/components/Settings/settings-fields";
6
6
  import {
7
7
  useNotifySettings,
8
8
  useSaveTelegram,
@@ -16,11 +16,8 @@ export function PhoneNotifyForm() {
16
16
  const { data: settings, isPending } = useNotifySettings();
17
17
  const save = useSaveTelegram();
18
18
  const test = useTestNotify();
19
- const [token, setToken] = useState("");
20
- const [chatId, setChatId] = useState<string | null>(null);
21
19
  if (isPending || !settings)
22
20
  return <div className="bg-muted/40 h-32 animate-pulse rounded-xl" />;
23
- const chat = chatId ?? settings.telegram.chatId ?? "";
24
21
 
25
22
  return (
26
23
  <div className="space-y-5">
@@ -48,60 +45,45 @@ export function PhoneNotifyForm() {
48
45
  )}
49
46
  </div>
50
47
 
51
- <form
52
- className="space-y-3"
53
- onSubmit={(e) => {
54
- e.preventDefault();
55
- save.mutate(
56
- {
57
- ...(token.trim() && { telegramToken: token.trim() }),
58
- telegramChatId: chat,
59
- },
60
- { onSuccess: () => setToken("") }
61
- );
62
- }}
63
- >
48
+ <div className="space-y-2">
64
49
  <p className="label-mono text-muted-foreground">Telegram</p>
65
- <div className="space-y-2">
66
- <label className="text-sm font-medium" htmlFor="tg-token">
67
- Bot token
68
- </label>
69
- <Input
50
+ <SettingSection>
51
+ <TextSetting
52
+ id="tg-chat"
53
+ label="Chat id"
54
+ help="Where the bot sends alerts: your chat id with it, or @channel."
55
+ placeholder="123456789"
56
+ value={settings.telegram.chatId ?? ""}
57
+ validate={(t) =>
58
+ !t || /^-?\d+$|^@\w+$/.test(t)
59
+ ? null
60
+ : "A chat id is a whole number like 123456789, or @channel."
61
+ }
62
+ onSave={(t) => save.mutateAsync({ telegramChatId: t || null })}
63
+ />
64
+ <TextSetting
70
65
  id="tg-token"
71
- type="password"
72
- autoComplete="off"
73
- value={token}
74
- onChange={(e) => setToken(e.target.value)}
66
+ label="Bot token"
67
+ help="Write-only. Saved with the button, never on blur: a half-typed secret must not save."
68
+ secret
69
+ buttonLabel={settings.telegram.tokenSet ? "Replace" : "Connect"}
75
70
  placeholder={
76
- settings.telegram.tokenSet ? "Saved; type to replace" : "123:ABC…"
71
+ settings.telegram.tokenSet
72
+ ? "Connected; paste to replace"
73
+ : "123:ABC…"
77
74
  }
78
- className="h-11 font-mono md:h-9"
79
- />
80
- </div>
81
- <div className="space-y-2">
82
- <label className="text-sm font-medium" htmlFor="tg-chat">
83
- Chat id
84
- </label>
85
- <Input
86
- id="tg-chat"
87
- inputMode="numeric"
88
- value={chat}
89
- onChange={(e) => setChatId(e.target.value)}
90
- placeholder="Your chat id with the bot"
91
- className="h-11 font-mono md:h-9"
75
+ value=""
76
+ validate={(t) =>
77
+ !t
78
+ ? "Paste the bot token from BotFather."
79
+ : /\s/.test(t)
80
+ ? "A token has no spaces."
81
+ : null
82
+ }
83
+ onSave={(t) => save.mutateAsync({ telegramToken: t })}
92
84
  />
93
- </div>
94
- {save.error && (
95
- <p className="text-destructive text-sm">{save.error.message}</p>
96
- )}
85
+ </SettingSection>
97
86
  <div className="flex flex-wrap gap-2">
98
- <Button
99
- type="submit"
100
- className="h-11 md:h-9"
101
- disabled={save.isPending}
102
- >
103
- {save.isPending ? "Saving..." : "Save"}
104
- </Button>
105
87
  {settings.telegram.tokenSet && (
106
88
  <Button
107
89
  type="button"
@@ -109,10 +91,7 @@ export function PhoneNotifyForm() {
109
91
  className="text-muted-foreground h-11 md:h-9"
110
92
  disabled={save.isPending}
111
93
  onClick={() =>
112
- save.mutate(
113
- { telegramToken: null, telegramChatId: null },
114
- { onSuccess: () => setChatId(null) }
115
- )
94
+ save.mutate({ telegramToken: null, telegramChatId: null })
116
95
  }
117
96
  >
118
97
  Remove bot
@@ -136,7 +115,7 @@ export function PhoneNotifyForm() {
136
115
  {testSummary(test.data)}
137
116
  </p>
138
117
  )}
139
- </form>
118
+ </div>
140
119
  </div>
141
120
  );
142
121
  }
@@ -17,6 +17,7 @@ import {
17
17
  DropdownMenuTrigger,
18
18
  } from "@/components/ui/dropdown-menu";
19
19
  import { cn } from "@/lib/utils";
20
+ import { keepRenameFocus, useRenameInput } from "./RowParts";
20
21
  import { SessionRow } from "./SessionRow";
21
22
  import { useDropZone } from "./useGroupDrag";
22
23
 
@@ -48,18 +49,18 @@ function GroupHeader({
48
49
  update.mutate({ id: group.id, name: next });
49
50
  else setName(group.name);
50
51
  };
52
+ const renameInput = useRenameInput(() => commit());
51
53
 
52
54
  if (renaming)
53
55
  return (
54
56
  <div className="px-2.5 pt-3.5 pb-1.5">
55
57
  <Input
56
- autoFocus
58
+ {...renameInput}
57
59
  value={name}
58
60
  maxLength={60}
59
61
  aria-label="Group name"
60
62
  className="h-11 md:h-8"
61
63
  onChange={(e) => setName(e.target.value)}
62
- onBlur={commit}
63
64
  onKeyDown={(e) => {
64
65
  if (e.key === "Enter") commit();
65
66
  if (e.key === "Escape") {
@@ -112,7 +113,11 @@ function GroupHeader({
112
113
  <MoreHorizontal className="h-4 w-4" />
113
114
  </button>
114
115
  </DropdownMenuTrigger>
115
- <DropdownMenuContent align="end" className="w-44">
116
+ <DropdownMenuContent
117
+ align="end"
118
+ className="w-44"
119
+ onCloseAutoFocus={keepRenameFocus}
120
+ >
116
121
  <DropdownMenuItem
117
122
  className="min-h-11 md:min-h-8"
118
123
  onSelect={() => setRenaming(true)}
@@ -160,7 +160,7 @@ export const OrchestratorPinRow = memo(function OrchestratorPinRow({
160
160
  <DM.DropdownMenuContent
161
161
  align="end"
162
162
  className={ROW_MENU_CONTENT}
163
- onCloseAutoFocus={keepRenameFocus(renaming)}
163
+ onCloseAutoFocus={keepRenameFocus}
164
164
  onClick={(e) => e.stopPropagation()}
165
165
  >
166
166
  {menu("dropdown")}
@@ -175,7 +175,7 @@ export const OrchestratorPinRow = memo(function OrchestratorPinRow({
175
175
  <CM.ContextMenuTrigger asChild>{content}</CM.ContextMenuTrigger>
176
176
  <CM.ContextMenuContent
177
177
  className={ROW_MENU_CONTENT}
178
- onCloseAutoFocus={keepRenameFocus(renaming)}
178
+ onCloseAutoFocus={keepRenameFocus}
179
179
  >
180
180
  {menu("context")}
181
181
  </CM.ContextMenuContent>
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { Lightbulb } from "lucide-react";
4
- import { useEffect, useRef, useState, type ReactNode } from "react";
4
+ import { useCallback, useRef, useState, type ReactNode } from "react";
5
5
  import type { SidebarRow } from "@/lib/sidebar/shelves";
6
6
  import { cn } from "@/lib/utils";
7
7
 
@@ -61,12 +61,44 @@ export function CardBadge({ count }: { count: number }) {
61
61
  );
62
62
  }
63
63
 
64
- // For a menu's onCloseAutoFocus: while a rename is open, the input keeps the
65
- // focus instead of the menu's trigger getting it back.
66
- export const keepRenameFocus = (renaming: boolean) => (e: Event) => {
67
- if (renaming) e.preventDefault();
64
+ const RENAME_INPUT = "input[data-rename-input]";
65
+
66
+ function focusRename(el: HTMLInputElement) {
67
+ el.focus();
68
+ el.select();
69
+ }
70
+
71
+ // For a menu's onCloseAutoFocus: while a rename input is open it takes the
72
+ // focus the menu would hand back to its trigger. Reads the DOM, not state, so
73
+ // it can't see a render that hasn't landed yet.
74
+ export const keepRenameFocus = (e: Event) => {
75
+ const el = document.querySelector<HTMLInputElement>(RENAME_INPUT);
76
+ if (!el) return;
77
+ e.preventDefault();
78
+ focusRename(el);
68
79
  };
69
80
 
81
+ // Props for a rename input that was opened from a menu. While the menu is
82
+ // still on screen it traps focus, so the input waits for keepRenameFocus; a
83
+ // blur before the input has held focus once is never a commit.
84
+ export function useRenameInput(onDone: () => void) {
85
+ const hadFocus = useRef(false);
86
+ const ref = useCallback((el: HTMLInputElement | null) => {
87
+ hadFocus.current = false;
88
+ if (el && !document.querySelector('[role="menu"]')) focusRename(el);
89
+ }, []);
90
+ return {
91
+ ref,
92
+ "data-rename-input": "",
93
+ onFocus: () => {
94
+ hadFocus.current = true;
95
+ },
96
+ onBlur: () => {
97
+ if (hadFocus.current) onDone();
98
+ },
99
+ };
100
+ }
101
+
70
102
  export function RowRename({
71
103
  initial,
72
104
  onDone,
@@ -75,41 +107,13 @@ export function RowRename({
75
107
  onDone: (name: string | null) => void;
76
108
  }) {
77
109
  const [value, setValue] = useState(initial);
78
- const ref = useRef<HTMLInputElement>(null);
79
- const hadFocus = useRef(false);
80
- // The menu closing hands focus back to its trigger (RowShell stops that
81
- // while renaming); keep taking it until we hold it, then leave it alone.
82
- useEffect(() => {
83
- const take = () => {
84
- const el = ref.current;
85
- if (!el || hadFocus.current) return;
86
- el.focus();
87
- if (document.activeElement === el) {
88
- hadFocus.current = true;
89
- el.select();
90
- }
91
- };
92
- take();
93
- const t = setInterval(take, 50);
94
- const stop = setTimeout(() => clearInterval(t), 1000);
95
- return () => {
96
- clearInterval(t);
97
- clearTimeout(stop);
98
- };
99
- }, []);
110
+ const input = useRenameInput(() => onDone(value.trim() || null));
100
111
  return (
101
112
  <input
102
- ref={ref}
113
+ {...input}
103
114
  value={value}
104
115
  aria-label="Session name"
105
116
  onChange={(e) => setValue(e.target.value)}
106
- onFocus={() => {
107
- hadFocus.current = true;
108
- }}
109
- onBlur={() => {
110
- // Only once we've held focus: the menu closing hands it to its trigger.
111
- if (hadFocus.current) onDone(value.trim() || null);
112
- }}
113
117
  onKeyDown={(e) => {
114
118
  e.stopPropagation();
115
119
  if (e.key === "Enter") onDone(value.trim() || null);
@@ -2,12 +2,16 @@ import "@/components/Chat/composer/test-dom";
2
2
  import { act, createElement, useState } from "react";
3
3
  import { createRoot, type Root } from "react-dom/client";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
- import { keepRenameFocus, RowRename } from "./RowParts";
5
+ import * as DM from "@/components/ui/dropdown-menu";
6
+ import { keepRenameFocus, RowRename, useRenameInput } from "./RowParts";
6
7
 
7
8
  (
8
9
  globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
9
10
  ).IS_REACT_ACT_ENVIRONMENT = true;
10
11
 
12
+ // Radix dispatches CustomEvents; node's own class is rejected by jsdom.
13
+ (globalThis as { CustomEvent?: unknown }).CustomEvent = window.CustomEvent;
14
+
11
15
  let root: Root;
12
16
  let host: HTMLElement;
13
17
  const input = () => host.querySelector("input")!;
@@ -28,23 +32,87 @@ function mount(onDone: (n: string | null) => void, initial = "old") {
28
32
  act(() => root.render(createElement(RowRename, { initial, onDone })));
29
33
  }
30
34
 
35
+ // A row whose menu has a Rename item, wired like RowShell / GroupShelves.
36
+ function Row({ group = false }: { group?: boolean }) {
37
+ const [renaming, setRenaming] = useState(false);
38
+ const [name, setName] = useState("old");
39
+ const rename = useRenameInput(() => setRenaming(false));
40
+ const menu = createElement(
41
+ DM.DropdownMenu,
42
+ { defaultOpen: true },
43
+ createElement(
44
+ DM.DropdownMenuTrigger,
45
+ { asChild: true },
46
+ createElement("button", { "aria-label": "menu" })
47
+ ),
48
+ createElement(
49
+ DM.DropdownMenuContent,
50
+ { onCloseAutoFocus: keepRenameFocus },
51
+ createElement(
52
+ DM.DropdownMenuItem,
53
+ { onSelect: () => setRenaming(true) },
54
+ "Rename"
55
+ )
56
+ )
57
+ );
58
+ return createElement(
59
+ "div",
60
+ null,
61
+ menu,
62
+ renaming
63
+ ? group
64
+ ? createElement("input", {
65
+ ...rename,
66
+ value: name,
67
+ onChange: (e: { target: { value: string } }) =>
68
+ setName(e.target.value),
69
+ })
70
+ : createElement(RowRename, {
71
+ initial: name,
72
+ onDone: () => setRenaming(false),
73
+ })
74
+ : null
75
+ );
76
+ }
77
+
78
+ describe.each([
79
+ ["session row", false],
80
+ ["group header", true],
81
+ ])("rename from the menu on a fresh mount (%s)", (_n, group) => {
82
+ it("keeps the input open, focused, and accepting typing", async () => {
83
+ vi.useRealTimers();
84
+ act(() => root.render(createElement(Row, { group })));
85
+ const item = [...document.querySelectorAll('[role="menuitem"]')].find(
86
+ (el) => el.textContent === "Rename"
87
+ ) as HTMLElement;
88
+ expect(item).toBeTruthy();
89
+ await act(async () => {
90
+ item.click();
91
+ await new Promise((r) => setTimeout(r, 100));
92
+ });
93
+ const el = input();
94
+ expect(el).toBeTruthy();
95
+ expect(document.activeElement).toBe(el);
96
+ const set = Object.getOwnPropertyDescriptor(
97
+ window.HTMLInputElement.prototype,
98
+ "value"
99
+ )!.set!;
100
+ act(() => {
101
+ set.call(el, "typed");
102
+ el.dispatchEvent(new window.Event("input", { bubbles: true }));
103
+ });
104
+ expect(input()).toBe(el);
105
+ expect(input().value).toBe("typed");
106
+ });
107
+ });
108
+
31
109
  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
- );
110
+ it("ignores a blur that comes before it was ever focused", () => {
41
111
  const done = vi.fn();
42
112
  mount(done);
113
+ // an open menu keeps the input from taking focus on mount
43
114
  act(() => input().dispatchEvent(new window.FocusEvent("blur")));
44
115
  expect(done).not.toHaveBeenCalled();
45
- act(() => vi.advanceTimersByTime(150));
46
- expect(document.activeElement).toBe(input());
47
- vi.restoreAllMocks();
48
116
  });
49
117
 
50
118
  it("keeps typed text across a parent re-render (list refresh)", () => {
@@ -75,7 +143,6 @@ describe("RowRename", () => {
75
143
  it("saves on Enter, cancels on Escape, saves on blur once focused", () => {
76
144
  const done = vi.fn();
77
145
  mount(done);
78
- act(() => vi.advanceTimersByTime(60));
79
146
  const key = (k: string) =>
80
147
  act(() => {
81
148
  input().dispatchEvent(
@@ -92,11 +159,13 @@ describe("RowRename", () => {
92
159
  });
93
160
 
94
161
  describe("keepRenameFocus", () => {
95
- it("stops the menu returning focus only while renaming", () => {
162
+ it("takes the focus the menu would return, only while a rename input is open", () => {
96
163
  const e = { preventDefault: vi.fn() } as unknown as Event;
97
- keepRenameFocus(false)(e);
164
+ keepRenameFocus(e);
98
165
  expect(e.preventDefault).not.toHaveBeenCalled();
99
- keepRenameFocus(true)(e);
166
+ mount(() => {});
167
+ keepRenameFocus(e);
100
168
  expect(e.preventDefault).toHaveBeenCalled();
169
+ expect(document.activeElement).toBe(input());
101
170
  });
102
171
  });
@@ -42,7 +42,6 @@ export function RowShell({
42
42
  menu,
43
43
  menuLabel,
44
44
  menuButton = true,
45
- renaming = false,
46
45
  }: {
47
46
  title: string;
48
47
  tipKey?: string;
@@ -62,8 +61,6 @@ export function RowShell({
62
61
  menuLabel: string;
63
62
  // False keeps the right-click menu but hides the ⋯ (while selecting).
64
63
  menuButton?: boolean;
65
- // A rename input is open: closing the menu mustn't move focus off it.
66
- renaming?: boolean;
67
64
  }) {
68
65
  const content = (
69
66
  <div
@@ -118,7 +115,7 @@ export function RowShell({
118
115
  <DM.DropdownMenuContent
119
116
  align="end"
120
117
  className={ROW_MENU_CONTENT}
121
- onCloseAutoFocus={keepRenameFocus(renaming)}
118
+ onCloseAutoFocus={keepRenameFocus}
122
119
  onClick={(e) => e.stopPropagation()}
123
120
  >
124
121
  {menu("dropdown")}
@@ -133,7 +130,7 @@ export function RowShell({
133
130
  <CM.ContextMenuTrigger asChild>{content}</CM.ContextMenuTrigger>
134
131
  <CM.ContextMenuContent
135
132
  className={ROW_MENU_CONTENT}
136
- onCloseAutoFocus={keepRenameFocus(renaming)}
133
+ onCloseAutoFocus={keepRenameFocus}
137
134
  >
138
135
  {menu("context")}
139
136
  </CM.ContextMenuContent>
@@ -127,7 +127,6 @@ export const SessionRow = memo(function SessionRow({
127
127
  menu={menu}
128
128
  menuLabel="Session actions"
129
129
  menuButton={!selecting}
130
- renaming={renaming}
131
130
  >
132
131
  {renaming ? (
133
132
  <RowRename
@@ -0,0 +1,61 @@
1
+ "use client";
2
+
3
+ import { useAwaySettingsQuery, useUpdateAwaySettings } from "@/data/away";
4
+ import { useStallQuery, useUpdateStall } from "@/data/stall";
5
+ import { MAX_STALL_MINUTES, MIN_STALL_MINUTES } from "@/lib/chat/stall-limits";
6
+ import {
7
+ MAX_WINDOW_MINUTES,
8
+ MIN_WINDOW_MINUTES,
9
+ } from "@/lib/away/window-limits";
10
+ import { SETTING_DEFAULTS } from "@/lib/settings/defaults";
11
+ import { SettingSection } from "./SettingRow";
12
+ import { NumberSetting } from "./settings-fields";
13
+
14
+ // Stall alerts and the Away window: app-wide, each saved on its own.
15
+ export function AppWideLimits() {
16
+ const stall = useStallQuery();
17
+ const away = useAwaySettingsQuery();
18
+ const updateStall = useUpdateStall();
19
+ const updateAway = useUpdateAwaySettings();
20
+ return (
21
+ <SettingSection
22
+ title="All workspaces"
23
+ help="Stall alerts and Away mode apply across every workspace."
24
+ >
25
+ {stall.data && (
26
+ <NumberSetting
27
+ id="stall-minutes"
28
+ label="Flag stalled turns"
29
+ help="Warn, and tell the orchestrator, when a turn makes no progress for this long."
30
+ value={stall.data.minutes}
31
+ defaultValue={SETTING_DEFAULTS.stallMinutes}
32
+ onSave={(n) => updateStall.mutateAsync(n)}
33
+ min={MIN_STALL_MINUTES}
34
+ max={MAX_STALL_MINUTES}
35
+ unit="minutes"
36
+ short=" min"
37
+ optional={{ off: 0, on: SETTING_DEFAULTS.stallMinutes }}
38
+ />
39
+ )}
40
+ {away.data && (
41
+ <NumberSetting
42
+ id="away-window"
43
+ label="Away window"
44
+ help="Away mode only works through sessions with a real turn this recently."
45
+ value={away.data.windowMinutes}
46
+ defaultValue={SETTING_DEFAULTS.awayWindowMinutes}
47
+ onSave={(n) => updateAway.mutateAsync({ windowMinutes: n })}
48
+ min={MIN_WINDOW_MINUTES}
49
+ max={MAX_WINDOW_MINUTES}
50
+ unit="minutes"
51
+ short=" min"
52
+ />
53
+ )}
54
+ {(stall.isError || away.isError) && (
55
+ <p className="text-destructive px-3 py-2 text-sm">
56
+ {stall.error?.message ?? away.error?.message}
57
+ </p>
58
+ )}
59
+ </SettingSection>
60
+ );
61
+ }
@@ -0,0 +1,96 @@
1
+ "use client";
2
+
3
+ import { useFreshStartQuery, useUpdateFreshStart } from "@/data/fresh-start";
4
+ import type { RefreshSettings } from "@/lib/chat/refresh";
5
+ import {
6
+ MAX_REFRESH_TOKENS,
7
+ MIN_REFRESH_TOKENS,
8
+ } from "@/lib/chat/refresh-limits";
9
+ import {
10
+ SETTING_DEFAULTS,
11
+ freshOnValue,
12
+ type FreshKey,
13
+ } from "@/lib/settings/defaults";
14
+ import { SettingSection } from "./SettingRow";
15
+ import { NumberSetting } from "./settings-fields";
16
+
17
+ type Row = {
18
+ key: keyof RefreshSettings & FreshKey;
19
+ label: string;
20
+ help: string;
21
+ };
22
+
23
+ const FRESH: Row[] = [
24
+ {
25
+ key: "orchestratorTokens",
26
+ label: "Orchestrators",
27
+ help: "Start fresh past this size, at the next turn. Its brief, rules and the workspace's state carry over.",
28
+ },
29
+ {
30
+ key: "taskTokens",
31
+ label: "Tasks",
32
+ help: "A task keeps only its brief and its branch.",
33
+ },
34
+ ];
35
+
36
+ const COMPACT: Row[] = [
37
+ {
38
+ key: "autoCompactTokens",
39
+ label: "Mid-turn, tasks",
40
+ help: "A task's agent compacts itself while a job runs long. Keep it high: it is holding its files.",
41
+ },
42
+ {
43
+ key: "interactiveAutoCompactTokens",
44
+ label: "Mid-turn, your sessions",
45
+ help: "Your own chats and terminals. A terminal only gets this one.",
46
+ },
47
+ {
48
+ key: "compactTokens",
49
+ label: "Between turns",
50
+ help: "Compact chats and tasks before the next message. The composer offers first.",
51
+ },
52
+ ];
53
+
54
+ // When agents start fresh or compact: app-wide, each value saved on its own.
55
+ export function ContextSection() {
56
+ const { data, isPending, isError, error } = useFreshStartQuery();
57
+ const update = useUpdateFreshStart();
58
+ if (isPending)
59
+ return <div className="bg-muted/40 h-24 animate-pulse rounded-xl" />;
60
+ if (isError)
61
+ return <p className="text-destructive text-sm">{error.message}</p>;
62
+
63
+ const row = ({ key, label, help }: Row) => (
64
+ <NumberSetting
65
+ key={key}
66
+ id={`context-${key}`}
67
+ label={label}
68
+ help={help}
69
+ value={data[key]}
70
+ defaultValue={SETTING_DEFAULTS.fresh[key]}
71
+ onSave={(n) => update.mutateAsync({ [key]: n })}
72
+ min={MIN_REFRESH_TOKENS}
73
+ max={MAX_REFRESH_TOKENS}
74
+ scale={1000}
75
+ unit="k tokens"
76
+ short="k"
77
+ optional={{ off: 0, on: freshOnValue(key) }}
78
+ />
79
+ );
80
+ return (
81
+ <div className="space-y-1">
82
+ <SettingSection
83
+ title="Fresh starts"
84
+ help="When a conversation passes the size, its agent starts a new one with its brief."
85
+ >
86
+ {FRESH.map(row)}
87
+ </SettingSection>
88
+ <SettingSection
89
+ title="Compact"
90
+ help="Mid-turn, Claude Code summarizes itself; between turns, AgentOS compacts before the next message. AgentOS hands the task, files and todos back."
91
+ >
92
+ {COMPACT.map(row)}
93
+ </SettingSection>
94
+ </div>
95
+ );
96
+ }
@@ -29,7 +29,9 @@ const w = (calls: number) => ({
29
29
  dismissed: 1,
30
30
  acceptRate: 0.5,
31
31
  });
32
- const state = vi.hoisted(() => ({ mutate: vi.fn() }));
32
+ const state = vi.hoisted(() => ({
33
+ mutateAsync: vi.fn().mockResolvedValue({}),
34
+ }));
33
35
  vi.mock("@/data/experiments", () => ({
34
36
  useExperimentsQuery: () => ({
35
37
  isPending: false,
@@ -50,7 +52,7 @@ vi.mock("@/data/experiments", () => ({
50
52
  ],
51
53
  },
52
54
  }),
53
- useSetExperiment: () => ({ mutate: state.mutate }),
55
+ useSetExperiment: () => ({ mutateAsync: state.mutateAsync }),
54
56
  useExperimentDetailQuery: () => ({ isPending: true }),
55
57
  }));
56
58
 
@@ -59,7 +61,7 @@ const { ExperimentsSection } = await import("./ExperimentsSection");
59
61
  let root: Root;
60
62
  let host: HTMLDivElement;
61
63
  beforeEach(() => {
62
- state.mutate.mockReset();
64
+ state.mutateAsync.mockClear();
63
65
  host = document.createElement("div");
64
66
  document.body.appendChild(host);
65
67
  root = createRoot(host);
@@ -87,9 +89,9 @@ describe("ExperimentsSection", () => {
87
89
  .querySelector('[role="switch"]')
88
90
  ?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
89
91
  });
90
- expect(state.mutate).toHaveBeenCalledWith(
91
- { id: "learning-cards", on: true },
92
- expect.any(Object)
93
- );
92
+ expect(state.mutateAsync).toHaveBeenCalledWith({
93
+ id: "learning-cards",
94
+ on: true,
95
+ });
94
96
  });
95
97
  });