@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
@@ -0,0 +1,6 @@
1
+ /** The one line the parent's chat keeps where forks were started. */
2
+
3
+ export const FORK_NOTE_PREFIX = "fork-";
4
+
5
+ export const startedText = (ns: number[], labels: string[]): string =>
6
+ `${ns.length === 1 ? `Fork ${ns[0]}` : `Forks ${ns.join(", ")}`} started · ${labels.join(" · ")}`;
package/lib/fork/start.ts CHANGED
@@ -18,6 +18,7 @@ import { getProject } from "../projects";
18
18
  import { limitOf, runningIn, startingIn } from "../tasks/queue-rows";
19
19
  import { currentLevel, loadEnabled } from "../load/monitor";
20
20
  import { childFirstMessage } from "./brief";
21
+ import { FORK_NOTE_PREFIX, startedText } from "./note";
21
22
  import { ForkError, forkConversation } from "./conversation";
22
23
  import {
23
24
  childrenOf,
@@ -120,7 +121,7 @@ export function startFork(req: ForkRequest): ForkStarted {
120
121
  instruction,
121
122
  worktree,
122
123
  }));
123
- const noteId = `fork-${groupId}`;
124
+ const noteId = `${FORK_NOTE_PREFIX}${groupId}`;
124
125
  insertGroup({
125
126
  id: groupId,
126
127
  parentId: parent.id,
@@ -136,7 +137,10 @@ export function startFork(req: ForkRequest): ForkStarted {
136
137
  kind: "note" as const,
137
138
  tone: "note" as const,
138
139
  createdAt: Date.now(),
139
- text: `Forked ${children.length === 1 ? "1 copy" : `${children.length} copies`}: ${children.map((c) => c.label).join(" · ")}`,
140
+ text: startedText(
141
+ children.map((c) => numberOf(parent.id, c.childId)),
142
+ children.map((c) => c.label)
143
+ ),
140
144
  };
141
145
  saveItem(parent.id, item);
142
146
  emit(parent.id, { type: "item", item });
package/lib/fork/types.ts CHANGED
@@ -71,6 +71,8 @@ export interface ChildView {
71
71
  delivered: boolean;
72
72
  // A question or approval it's waiting on.
73
73
  asks: string | null;
74
+ // What it is doing now (its latest tool), while it runs.
75
+ activity: string | null;
74
76
  }
75
77
 
76
78
  export interface GroupView {
package/lib/fork/view.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /** What clients and `aos children` show, and the numbers behind them. */
2
2
 
3
+ import { lastItems } from "../chat/store";
3
4
  import { childrenOf, getGroup } from "./ledger";
4
5
  import type { ChildRow, ChildView, GroupView } from "./types";
5
6
 
@@ -21,8 +22,16 @@ export const childView = (c: ChildRow): ChildView => ({
21
22
  settledAt: c.settled_at,
22
23
  delivered: c.delivered_at != null,
23
24
  asks: c.state === "needs_input" ? c.hold : null,
25
+ activity: null,
24
26
  });
25
27
 
28
+ // The title of the latest tool a running child used, to say what it is doing.
29
+ const activityOf = (c: ChildRow): string | null => {
30
+ if (c.state !== "running") return null;
31
+ const tool = lastItems(c.child_session_id, 1, "tool")[0];
32
+ return tool && tool.kind === "tool" ? tool.title : null;
33
+ };
34
+
26
35
  export function childrenView(parentId: string): {
27
36
  children: ChildView[];
28
37
  groups: GroupView[];
@@ -45,7 +54,11 @@ export function childrenView(parentId: string): {
45
54
  });
46
55
  }
47
56
  return {
48
- children: rows.map((c, i) => ({ ...childView(c), n: i + 1 })),
57
+ children: rows.map((c, i) => ({
58
+ ...childView(c),
59
+ n: i + 1,
60
+ activity: activityOf(c),
61
+ })),
49
62
  groups,
50
63
  };
51
64
  }
@@ -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.833",
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
- }
@@ -1,101 +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 { useStallQuery, useUpdateStall } from "@/data/stall";
9
- import {
10
- DEFAULT_STALL_MINUTES,
11
- MAX_STALL_MINUTES,
12
- MIN_STALL_MINUTES,
13
- } from "@/lib/chat/stall-limits";
14
-
15
- // How long a turn may produce nothing before it's flagged stalled and the
16
- // workspace's orchestrator is told: app-wide.
17
- export function StallForm() {
18
- const { data, isPending, isError, error } = useStallQuery();
19
- if (isPending)
20
- return <div className="bg-muted/40 h-24 animate-pulse rounded-lg" />;
21
- if (isError)
22
- return <p className="text-destructive text-sm">{error.message}</p>;
23
- return <Form key={data.minutes} saved={data.minutes} />;
24
- }
25
-
26
- function Form({ saved }: { saved: number }) {
27
- const update = useUpdateStall();
28
- const [on, setOn] = useState(saved > 0);
29
- const [text, setText] = useState(
30
- String(saved > 0 ? saved : DEFAULT_STALL_MINUTES)
31
- );
32
- const n = Number(text);
33
- const valid =
34
- Number.isInteger(n) && n >= MIN_STALL_MINUTES && n <= MAX_STALL_MINUTES;
35
- const minutes = on ? n : 0;
36
- const error =
37
- on && !valid
38
- ? `Between ${MIN_STALL_MINUTES} and ${MAX_STALL_MINUTES} minutes`
39
- : null;
40
- const dirty = !error && minutes !== saved;
41
-
42
- return (
43
- <form
44
- onSubmit={(e) => {
45
- e.preventDefault();
46
- if (error) return;
47
- update.mutate(minutes, {
48
- onSuccess: () => toast.success("Stall alert saved"),
49
- onError: (err) => toast.error(err.message),
50
- });
51
- }}
52
- className="bg-muted/40 space-y-3 rounded-lg px-3 py-3"
53
- >
54
- <h3 className="text-sm font-medium">Stalled turns</h3>
55
- <p className="text-muted-foreground text-sm">
56
- A turn that produces nothing for this long (no text, tool call or
57
- result) shows as stalled, and the workspace&apos;s orchestrator is told
58
- once. A long command that is still printing, or a background command the
59
- agent is waiting on, isn&apos;t a stall.
60
- </p>
61
- <label className="flex min-h-11 items-center justify-between gap-3 text-sm md:min-h-9">
62
- Flag stalled turns
63
- <Switch
64
- checked={on}
65
- onCheckedChange={setOn}
66
- aria-label="Flag stalled turns"
67
- />
68
- </label>
69
- {on && (
70
- <label className="flex items-center justify-between gap-3 text-sm">
71
- <span className="text-muted-foreground">After</span>
72
- <span className="flex items-center gap-2">
73
- <Input
74
- type="number"
75
- inputMode="numeric"
76
- min={MIN_STALL_MINUTES}
77
- max={MAX_STALL_MINUTES}
78
- value={text}
79
- onChange={(e) => setText(e.target.value)}
80
- aria-label="Stall threshold, minutes"
81
- aria-invalid={!!error}
82
- className="h-11 w-24 text-base sm:h-9 md:text-sm"
83
- />
84
- <span className="text-muted-foreground">minutes</span>
85
- </span>
86
- </label>
87
- )}
88
- {error && <p className="text-destructive text-xs">{error}</p>}
89
- <div className="flex justify-end">
90
- <Button
91
- type="submit"
92
- size="sm"
93
- className="h-11 sm:h-8"
94
- disabled={!!error || !dirty || update.isPending}
95
- >
96
- Save
97
- </Button>
98
- </div>
99
- </form>
100
- );
101
- }
@@ -1,84 +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 { Workspace } from "@/lib/db";
9
- import { useUpdateWorkspace } from "@/data/workspaces";
10
- import { parseTaskLimit } from "./task-limit";
11
-
12
- // How many tasks the workspace runs at once. Off by default; over the limit
13
- // a new task is queued and starts by itself when one finishes.
14
- export function TaskLimitForm({ workspace }: { workspace: Workspace }) {
15
- const update = useUpdateWorkspace();
16
- const current = workspace.max_running_tasks;
17
- const [on, setOn] = useState(current !== null);
18
- const [text, setText] = useState(String(current ?? 3));
19
- const parsed = parseTaskLimit(on, text);
20
- const dirty = !("error" in parsed) && parsed.limit !== current;
21
-
22
- return (
23
- <form
24
- onSubmit={(e) => {
25
- e.preventDefault();
26
- if ("error" in parsed) return;
27
- update.mutate(
28
- { id: workspace.id, maxRunningTasks: parsed.limit },
29
- {
30
- onSuccess: () =>
31
- toast.success(
32
- parsed.limit
33
- ? `Up to ${parsed.limit} tasks run at once`
34
- : "No running task limit"
35
- ),
36
- onError: (error) => toast.error(error.message),
37
- }
38
- );
39
- }}
40
- className="space-y-4"
41
- >
42
- <label className="flex min-h-11 items-center justify-between gap-3 text-sm md:min-h-9">
43
- Limit running tasks
44
- <Switch
45
- checked={on}
46
- onCheckedChange={setOn}
47
- aria-label="Limit running tasks"
48
- />
49
- </label>
50
- {on && (
51
- <label className="flex items-center justify-between gap-3 text-sm">
52
- <span>At most</span>
53
- <span className="flex items-center gap-2">
54
- <Input
55
- type="number"
56
- inputMode="numeric"
57
- min={1}
58
- step={1}
59
- value={text}
60
- onChange={(e) => setText(e.target.value)}
61
- aria-label="Running task limit"
62
- aria-invalid={"error" in parsed}
63
- className="h-11 w-20 text-base sm:h-9 md:text-sm"
64
- />
65
- <span className="text-muted-foreground">at a time</span>
66
- </span>
67
- </label>
68
- )}
69
- {"error" in parsed && (
70
- <p className="text-destructive text-xs">{parsed.error}</p>
71
- )}
72
- <div className="flex justify-end">
73
- <Button
74
- type="submit"
75
- size="sm"
76
- className="h-11 sm:h-8"
77
- disabled={"error" in parsed || !dirty || update.isPending}
78
- >
79
- Save
80
- </Button>
81
- </div>
82
- </form>
83
- );
84
- }
@@ -1,19 +0,0 @@
1
- import {
2
- MAX_REFRESH_TOKENS,
3
- MIN_REFRESH_TOKENS,
4
- } from "@/lib/chat/refresh-limits";
5
-
6
- // A threshold's input, in thousands of tokens: off is 0; on takes a whole
7
- // number in the range the server holds (lib/chat/refresh).
8
- export function parseFreshStart(
9
- on: boolean,
10
- text: string
11
- ): { tokens: number } | { error: string } {
12
- if (!on) return { tokens: 0 };
13
- const k = Number(text.trim());
14
- const min = MIN_REFRESH_TOKENS / 1000;
15
- const max = MAX_REFRESH_TOKENS / 1000;
16
- if (!text.trim() || !Number.isInteger(k) || k < min || k > max)
17
- return { error: `A whole number from ${min} to ${max}` };
18
- return { tokens: k * 1000 };
19
- }