@saadnvd1/agent-os 0.3.833 → 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 (74) 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/sessions/route.ts +7 -1
  4. package/app/globals.css +14 -0
  5. package/components/Chat/ChatPanel.tsx +41 -29
  6. package/components/Chat/Forks.tsx +16 -1
  7. package/components/Chat/Messages.tsx +14 -0
  8. package/components/Chat/WorktreeRemovedNotice.tsx +17 -0
  9. package/components/Chat/composer/ComposerBody.tsx +10 -1
  10. package/components/Chat/composer/ForkHeader.tsx +26 -15
  11. package/components/Chat/fork-draft.test.ts +57 -0
  12. package/components/Chat/fork-draft.ts +34 -0
  13. package/components/Devices/DeviceRow.tsx +83 -69
  14. package/components/Devices/PasskeysSection.test.ts +89 -0
  15. package/components/Devices/PasskeysSection.tsx +36 -26
  16. package/components/Health/HealthPanel.tsx +41 -16
  17. package/components/Merge/GlobalMergeSection.tsx +2 -5
  18. package/components/Merge/MergeSettingsFields.tsx +39 -77
  19. package/components/Merge/ProjectMergeSection.tsx +2 -6
  20. package/components/Merge/WorktreeSweepSection.tsx +40 -14
  21. package/components/Models/AgentRoleCard.tsx +61 -66
  22. package/components/Models/AgentRolesSection.tsx +5 -8
  23. package/components/Models/ModelsSection.tsx +7 -18
  24. package/components/Models/PriceInput.tsx +59 -0
  25. package/components/PhoneNotify/PhoneNotifyForm.tsx +37 -58
  26. package/components/Settings/AppWideLimits.tsx +61 -0
  27. package/components/Settings/ContextSection.tsx +96 -0
  28. package/components/Settings/ExperimentsSection.test.ts +9 -7
  29. package/components/Settings/ExperimentsSection.tsx +22 -23
  30. package/components/Settings/InlineConfirm.tsx +51 -0
  31. package/components/Settings/OwnerNameRow.tsx +10 -40
  32. package/components/Settings/PickerSetting.tsx +59 -0
  33. package/components/Settings/SettingRow.tsx +131 -0
  34. package/components/Settings/SettingsDialog.test.ts +1 -0
  35. package/components/Settings/SettingsDialog.tsx +47 -2
  36. package/components/Settings/settings-fields.test.ts +103 -0
  37. package/components/Settings/settings-fields.tsx +427 -0
  38. package/components/Shares/SharedLinksPanel.tsx +62 -53
  39. package/components/Workspaces/HqFolderForm.tsx +21 -54
  40. package/components/Workspaces/McpServersForm.tsx +20 -19
  41. package/components/Workspaces/ProjectTiersForm.tsx +10 -8
  42. package/components/Workspaces/RulesForm.test.ts +127 -0
  43. package/components/Workspaces/RulesForm.tsx +54 -126
  44. package/components/Workspaces/WorkspaceModelsForm.tsx +27 -26
  45. package/components/Workspaces/WorkspaceSettings.tsx +61 -24
  46. package/content/whats-new.json +32 -0
  47. package/data/fresh-start/index.ts +1 -1
  48. package/data/workspaces/index.ts +9 -0
  49. package/hooks/useAutoSave.test.ts +116 -0
  50. package/hooks/useAutoSave.ts +98 -0
  51. package/lib/away/window-limits.ts +3 -1
  52. package/lib/chat/refresh.ts +10 -6
  53. package/lib/chat/socket.ts +7 -3
  54. package/lib/chat/stall-limits.ts +3 -1
  55. package/lib/chat/start-error.ts +13 -0
  56. package/lib/chat/worker/cwd.ts +77 -0
  57. package/lib/chat/worker/host.ts +11 -2
  58. package/lib/chat/worker/main.ts +11 -2
  59. package/lib/db/types.ts +2 -0
  60. package/lib/fork/note.ts +6 -0
  61. package/lib/fork/start.ts +6 -2
  62. package/lib/fork/types.ts +2 -0
  63. package/lib/fork/view.ts +14 -1
  64. package/lib/settings/defaults.ts +27 -0
  65. package/lib/settings/parse.ts +17 -0
  66. package/lib/settings-url.ts +1 -0
  67. package/lib/worktree-sweep/limits.ts +3 -0
  68. package/lib/worktree-sweep/settings.ts +4 -2
  69. package/package.json +1 -1
  70. package/components/Away/AwayWindowForm.tsx +0 -91
  71. package/components/Workspaces/FreshStartForm.tsx +0 -177
  72. package/components/Workspaces/StallForm.tsx +0 -101
  73. package/components/Workspaces/TaskLimitForm.tsx +0 -84
  74. package/components/Workspaces/fresh-start.ts +0 -19
@@ -5,6 +5,8 @@ import { Input } from "@/components/ui/input";
5
5
  import { useSetSweepDays, useWorktreeSweep } from "@/data/worktree-sweep";
6
6
  import { compactTimeAgo, fromSqliteTime } from "@/lib/session-meta";
7
7
  import { formatBytes } from "@/lib/share/view";
8
+ import { parseSettingNumber } from "@/lib/settings/parse";
9
+ import { MAX_SWEEP_DAYS } from "@/lib/worktree-sweep/limits";
8
10
 
9
11
  // Days as the box shows them; empty means "inherit".
10
12
  function DaysInput({
@@ -19,24 +21,48 @@ function DaysInput({
19
21
  onSave: (days: number | null) => void;
20
22
  }) {
21
23
  const [text, setText] = useState(value === null ? "" : String(value));
24
+ const [error, setError] = useState<string | null>(null);
22
25
  const commit = () => {
23
26
  const raw = text.trim();
24
- const next = raw === "" ? null : Number(raw);
25
- if (next === value || (next !== null && !Number.isInteger(next))) return;
26
- onSave(next);
27
+ let next: number | null = null;
28
+ if (raw !== "") {
29
+ const parsed = parseSettingNumber(raw, { min: 0, max: MAX_SWEEP_DAYS });
30
+ if ("error" in parsed) return setError(parsed.error);
31
+ next = parsed.value;
32
+ }
33
+ setError(null);
34
+ if (next !== value) onSave(next);
27
35
  };
28
36
  return (
29
- <Input
30
- aria-label="Days before a settled worktree is removed"
31
- inputMode="numeric"
32
- value={text}
33
- placeholder={placeholder}
34
- disabled={disabled}
35
- onChange={(e) => setText(e.target.value)}
36
- onBlur={commit}
37
- onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()}
38
- className="h-11 w-24 text-right tabular-nums md:h-9"
39
- />
37
+ <span className="flex flex-col gap-1">
38
+ <Input
39
+ aria-label="Days before a settled worktree is removed"
40
+ aria-invalid={!!error}
41
+ inputMode="numeric"
42
+ value={text}
43
+ placeholder={placeholder}
44
+ disabled={disabled}
45
+ onChange={(e) => {
46
+ setText(e.target.value);
47
+ setError(null);
48
+ }}
49
+ onBlur={commit}
50
+ onKeyDown={(e) => {
51
+ if (e.key === "Enter") e.currentTarget.blur();
52
+ if (e.key === "Escape") {
53
+ setText(value === null ? "" : String(value));
54
+ setError(null);
55
+ e.currentTarget.blur();
56
+ }
57
+ }}
58
+ className="h-11 w-24 text-right tabular-nums md:h-9"
59
+ />
60
+ {error && (
61
+ <span role="alert" className="text-destructive text-xs">
62
+ {error}
63
+ </span>
64
+ )}
65
+ </span>
40
66
  );
41
67
  }
42
68
 
@@ -4,6 +4,7 @@ import { AlertTriangle } from "lucide-react";
4
4
  import type { AgentRoleView } from "@/lib/models/agent-roles-view";
5
5
  import { analystBlock, defaultAnalystLabel } from "@/lib/models/usable";
6
6
  import { cn } from "@/lib/utils";
7
+ import { PickerSetting } from "@/components/Settings/PickerSetting";
7
8
  import { RoleModelSelect } from "./RoleModelSelect";
8
9
 
9
10
  const SOURCE: Record<string, string> = {
@@ -16,22 +17,18 @@ const SOURCE: Record<string, string> = {
16
17
  default: "Default",
17
18
  };
18
19
 
19
- const initial = (label: string) => label.slice(0, 1);
20
-
21
- // One role: what it does, the model it runs on, where that came from, and
20
+ // One role as settings rows: the model it runs on, where that came from, and
22
21
  // whether the model's server answers. A role whose local model is down says so.
23
22
  export function AgentRoleCard({
24
23
  role,
25
24
  scope,
26
- pending,
27
25
  onChange,
28
26
  onFallback,
29
27
  }: {
30
28
  role: AgentRoleView;
31
29
  scope: "settings" | "workspace" | "project";
32
- pending: boolean;
33
- onChange: (ref: string | null) => void;
34
- onFallback: (ref: string | null) => void;
30
+ onChange: (ref: string | null) => Promise<unknown>;
31
+ onFallback: (ref: string | null) => Promise<unknown>;
35
32
  }) {
36
33
  const down = role.choice?.availability.state === "down";
37
34
  const analyst = role.id === "analyst";
@@ -45,77 +42,75 @@ export function AgentRoleCard({
45
42
  ? "Same as Gate review"
46
43
  : label
47
44
  : "Inherit";
45
+ const source = role.binding
46
+ ? `${SOURCE[role.binding.source] ?? role.binding.source}${role.choice?.endpoint ? ` · ${role.choice.endpoint}` : ""}`
47
+ : undefined;
48
48
  return (
49
- <li
50
- className={cn(
51
- "bg-muted/40 space-y-2 rounded-xl px-3 py-3",
52
- down && "ring-destructive/50 ring-1"
53
- )}
54
- >
55
- <label htmlFor={`agent-role-${role.id}`} className="flex gap-2.5">
56
- <span className="bg-accent text-primary grid h-8 w-8 shrink-0 place-items-center rounded-lg text-sm font-semibold">
57
- {initial(role.label)}
58
- </span>
59
- <span className="min-w-0">
60
- <span className="block text-sm font-medium">{role.label}</span>
61
- <span className="text-muted-foreground block text-xs">
62
- {role.purpose}
63
- </span>
64
- </span>
65
- </label>
66
- <RoleModelSelect
49
+ <>
50
+ <PickerSetting<string | null>
67
51
  id={`agent-role-${role.id}`}
52
+ label={role.label}
53
+ help={role.purpose}
68
54
  value={role.value}
69
- inheritLabel={
70
- role.value === null && role.binding
71
- ? `${inheritLabel} · ${role.binding.ref.split("/").slice(1).join("/")}`
72
- : inheritLabel
55
+ defaultValue={null}
56
+ hint={source}
57
+ error={role.error}
58
+ onSave={onChange}
59
+ render={({ value, commit }) => (
60
+ <RoleModelSelect
61
+ id={`agent-role-${role.id}`}
62
+ value={value}
63
+ inheritLabel={
64
+ value === null && role.binding
65
+ ? `${inheritLabel} · ${role.binding.ref.split("/").slice(1).join("/")}`
66
+ : inheritLabel
67
+ }
68
+ blockedBy={blockedBy}
69
+ onChange={commit}
70
+ />
71
+ )}
72
+ below={
73
+ down && (
74
+ <div
75
+ role="alert"
76
+ className={cn(
77
+ "bg-destructive/10 text-foreground ring-destructive/35 my-1 flex gap-2 rounded-lg p-2.5 text-xs ring-1"
78
+ )}
79
+ >
80
+ <AlertTriangle className="text-destructive mt-0.5 h-3.5 w-3.5 shrink-0" />
81
+ <span>
82
+ <b className="font-medium">
83
+ This model&apos;s server isn&apos;t answering.
84
+ </b>{" "}
85
+ {role.choice?.availability.error
86
+ ? `${role.choice.availability.error}. `
87
+ : ""}
88
+ New {role.label} sessions can&apos;t start until it is back
89
+ {role.fallback ? ", or you start on the fallback" : ""}.
90
+ </span>
91
+ </div>
92
+ )
73
93
  }
74
- blockedBy={blockedBy}
75
- disabled={pending}
76
- onChange={onChange}
77
94
  />
78
- <p className="text-muted-foreground text-xs">
79
- {role.error
80
- ? role.error
81
- : role.binding
82
- ? `${SOURCE[role.binding.source] ?? role.binding.source}${role.choice?.endpoint ? ` · ${role.choice.endpoint}` : ""}`
83
- : ""}
84
- </p>
85
- {down && (
86
- <div
87
- role="alert"
88
- className="bg-destructive/10 text-foreground ring-destructive/35 flex gap-2 rounded-lg p-2.5 text-xs ring-1"
89
- >
90
- <AlertTriangle className="text-destructive mt-0.5 h-3.5 w-3.5 shrink-0" />
91
- <span>
92
- <b className="font-medium">
93
- This model&apos;s server isn&apos;t answering.
94
- </b>{" "}
95
- {role.choice?.availability.error
96
- ? `${role.choice.availability.error}. `
97
- : ""}
98
- New {role.label} sessions can&apos;t start until it is back
99
- {role.fallback ? ", or you start on the fallback" : ""}.
100
- </span>
101
- </div>
102
- )}
103
95
  {scope === "settings" && (
104
- <div className="flex flex-wrap items-center gap-2">
105
- <span className="text-muted-foreground text-xs">Fallback</span>
106
- <div className="min-w-44 flex-1">
96
+ <PickerSetting<string | null>
97
+ id={`agent-role-fallback-${role.id}`}
98
+ label={`${role.label} fallback`}
99
+ value={role.fallback}
100
+ defaultValue={null}
101
+ onSave={onFallback}
102
+ render={({ value, commit }) => (
107
103
  <RoleModelSelect
108
104
  id={`agent-role-fallback-${role.id}`}
109
- value={role.fallback}
105
+ value={value}
110
106
  inheritLabel="None (don't fall back)"
111
107
  claudeOnly={!analyst}
112
108
  blockedBy={blockedBy}
113
- disabled={pending}
114
- onChange={onFallback}
109
+ onChange={commit}
115
110
  />
116
- </div>
117
- </div>
111
+ )}
112
+ />
118
113
  )}
119
- </li>
114
+ </>
120
115
  );
121
116
  }
@@ -7,6 +7,7 @@ import { useWorkspacesQuery } from "@/data/workspaces";
7
7
  import type { RoleMap } from "@/lib/models/config";
8
8
  import type { RoleScope } from "@/lib/models/agent-roles-view";
9
9
  import { cn } from "@/lib/utils";
10
+ import { SettingSection } from "@/components/Settings/SettingRow";
10
11
  import { AgentRoleCard } from "./AgentRoleCard";
11
12
 
12
13
  const SCOPES: { id: RoleScope; label: string }[] = [
@@ -44,13 +45,13 @@ export function AgentRolesSection() {
44
45
  const roles: RoleMap = { ...saved("value") };
45
46
  if (ref) roles[modelRole as keyof RoleMap] = ref;
46
47
  else delete roles[modelRole as keyof RoleMap];
47
- save.mutate({ roles });
48
+ return save.mutateAsync({ roles });
48
49
  };
49
50
  const setFallback = (modelRole: string, ref: string | null) => {
50
51
  const fallbacks: RoleMap = { ...saved("fallback") };
51
52
  if (ref) fallbacks[modelRole as keyof RoleMap] = ref;
52
53
  else delete fallbacks[modelRole as keyof RoleMap];
53
- save.mutate({ roles: saved("value"), fallbacks });
54
+ return save.mutateAsync({ roles: saved("value"), fallbacks });
54
55
  };
55
56
 
56
57
  return (
@@ -111,22 +112,18 @@ export function AgentRolesSection() {
111
112
  ) : isError ? (
112
113
  <p className="text-destructive text-sm">{error.message}</p>
113
114
  ) : data ? (
114
- <ul className="space-y-2.5">
115
+ <SettingSection>
115
116
  {data.roles.map((r) => (
116
117
  <AgentRoleCard
117
118
  key={r.id}
118
119
  role={r}
119
120
  scope={scope}
120
- pending={save.isPending}
121
121
  onChange={(ref) => setRole(r.modelRole, ref)}
122
122
  onFallback={(ref) => setFallback(r.modelRole, ref)}
123
123
  />
124
124
  ))}
125
- </ul>
125
+ </SettingSection>
126
126
  ) : null}
127
- {save.isError && (
128
- <p className="text-destructive text-xs">{save.error.message}</p>
129
- )}
130
127
  </div>
131
128
  );
132
129
  }
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { Button } from "@/components/ui/button";
4
- import { Input } from "@/components/ui/input";
4
+ import { PriceInput } from "./PriceInput";
5
5
  import { SettingToggle } from "@/components/Settings/SettingToggle";
6
6
  import { useModelsQuery, useUpdateModels } from "@/data/models";
7
7
  import type { ModelSettings } from "@/lib/models/config";
@@ -169,28 +169,17 @@ export function ModelsSection() {
169
169
  <td className="py-1 pr-2 font-mono">{ref}</td>
170
170
  {PRICE_FIELDS.map((f) => (
171
171
  <td key={f.key} className="py-1 pr-2">
172
- <Input
173
- type="number"
174
- min={0}
175
- step="0.001"
176
- defaultValue={price[f.key]}
177
- aria-label={`${ref} ${f.label}`}
178
- className="h-11 w-20 tabular-nums md:h-8"
179
- onBlur={(e) => {
180
- const v = Number(e.target.value);
181
- if (
182
- !Number.isFinite(v) ||
183
- v < 0 ||
184
- v === price[f.key]
185
- )
186
- return;
172
+ <PriceInput
173
+ label={`${ref} ${f.label}`}
174
+ value={price[f.key]}
175
+ onSave={(v) =>
187
176
  save({
188
177
  prices: {
189
178
  ...saved.prices,
190
179
  [ref]: { ...price, [f.key]: v },
191
180
  },
192
- });
193
- }}
181
+ })
182
+ }
194
183
  />
195
184
  </td>
196
185
  ))}
@@ -0,0 +1,59 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Input } from "@/components/ui/input";
5
+ import { parseSettingNumber } from "@/lib/settings/parse";
6
+
7
+ // A model price in $ per million tokens, saved on blur or Enter. Blank or
8
+ // invalid stays in the box with a message and is never sent (a blank used to
9
+ // save as $0); Esc puts the saved price back.
10
+ export function PriceInput({
11
+ label,
12
+ value,
13
+ onSave,
14
+ }: {
15
+ label: string;
16
+ value: number;
17
+ onSave: (price: number) => void;
18
+ }) {
19
+ const [text, setText] = useState<string | null>(null);
20
+ const [error, setError] = useState<string | null>(null);
21
+ const revert = () => {
22
+ setText(null);
23
+ setError(null);
24
+ };
25
+ return (
26
+ <span className="flex flex-col gap-1">
27
+ <Input
28
+ inputMode="decimal"
29
+ value={text ?? String(value)}
30
+ aria-label={label}
31
+ aria-invalid={!!error}
32
+ className="h-11 w-20 tabular-nums md:h-8"
33
+ onChange={(e) => {
34
+ setText(e.target.value);
35
+ setError(null);
36
+ }}
37
+ onBlur={() => {
38
+ if (text === null) return;
39
+ const parsed = parseSettingNumber(text, { min: 0, integer: false });
40
+ if ("error" in parsed) return setError(parsed.error);
41
+ revert();
42
+ if (parsed.value !== value) onSave(parsed.value);
43
+ }}
44
+ onKeyDown={(e) => {
45
+ if (e.key === "Enter") e.currentTarget.blur();
46
+ if (e.key === "Escape") {
47
+ revert();
48
+ e.currentTarget.blur();
49
+ }
50
+ }}
51
+ />
52
+ {error && (
53
+ <span role="alert" className="text-destructive text-xs">
54
+ {error}
55
+ </span>
56
+ )}
57
+ </span>
58
+ );
59
+ }
@@ -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
  }
@@ -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
+ }