@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.
- package/app/api/fresh-start/route.test.ts +33 -0
- package/app/api/fresh-start/route.ts +3 -0
- package/components/Devices/DeviceRow.tsx +83 -69
- package/components/Devices/PasskeysSection.test.ts +89 -0
- package/components/Devices/PasskeysSection.tsx +36 -26
- package/components/Health/HealthPanel.tsx +41 -16
- package/components/Merge/GlobalMergeSection.tsx +2 -5
- package/components/Merge/MergeSettingsFields.tsx +39 -77
- package/components/Merge/ProjectMergeSection.tsx +2 -6
- package/components/Merge/WorktreeSweepSection.tsx +40 -14
- package/components/Models/AgentRoleCard.tsx +61 -66
- package/components/Models/AgentRolesSection.tsx +5 -8
- package/components/Models/ModelsSection.tsx +7 -18
- package/components/Models/PriceInput.tsx +59 -0
- package/components/PhoneNotify/PhoneNotifyForm.tsx +37 -58
- package/components/Settings/AppWideLimits.tsx +61 -0
- package/components/Settings/ContextSection.tsx +96 -0
- package/components/Settings/ExperimentsSection.test.ts +9 -7
- package/components/Settings/ExperimentsSection.tsx +22 -23
- package/components/Settings/InlineConfirm.tsx +51 -0
- package/components/Settings/OwnerNameRow.tsx +10 -40
- package/components/Settings/PickerSetting.tsx +59 -0
- package/components/Settings/SettingRow.tsx +131 -0
- package/components/Settings/SettingsDialog.test.ts +1 -0
- package/components/Settings/SettingsDialog.tsx +47 -2
- package/components/Settings/settings-fields.test.ts +103 -0
- package/components/Settings/settings-fields.tsx +427 -0
- package/components/Shares/SharedLinksPanel.tsx +62 -53
- package/components/Workspaces/HqFolderForm.tsx +21 -54
- package/components/Workspaces/McpServersForm.tsx +20 -19
- package/components/Workspaces/ProjectTiersForm.tsx +10 -8
- package/components/Workspaces/RulesForm.test.ts +127 -0
- package/components/Workspaces/RulesForm.tsx +54 -126
- package/components/Workspaces/WorkspaceModelsForm.tsx +27 -26
- package/components/Workspaces/WorkspaceSettings.tsx +61 -24
- package/content/whats-new.json +16 -0
- package/data/fresh-start/index.ts +1 -1
- package/data/workspaces/index.ts +9 -0
- package/hooks/useAutoSave.test.ts +116 -0
- package/hooks/useAutoSave.ts +98 -0
- package/lib/away/window-limits.ts +3 -1
- package/lib/chat/refresh.ts +10 -6
- package/lib/chat/stall-limits.ts +3 -1
- package/lib/settings/defaults.ts +27 -0
- package/lib/settings/parse.ts +17 -0
- package/lib/settings-url.ts +1 -0
- package/lib/worktree-sweep/limits.ts +3 -0
- package/lib/worktree-sweep/settings.ts +4 -2
- package/package.json +1 -1
- package/components/Away/AwayWindowForm.tsx +0 -91
- package/components/Workspaces/FreshStartForm.tsx +0 -177
- package/components/Workspaces/StallForm.tsx +0 -101
- package/components/Workspaces/TaskLimitForm.tsx +0 -84
- 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: "
|
|
27
|
+
method: "PATCH",
|
|
28
28
|
headers: { "Content-Type": "application/json" },
|
|
29
29
|
body: JSON.stringify(settings),
|
|
30
30
|
})
|
package/data/workspaces/index.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
package/lib/chat/refresh.ts
CHANGED
|
@@ -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 →
|
|
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 =
|
|
25
|
-
|
|
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 =
|
|
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 =
|
|
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,
|
|
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(
|
package/lib/chat/stall-limits.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|
package/lib/settings-url.ts
CHANGED
|
@@ -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 =
|
|
11
|
-
export
|
|
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,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'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'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'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
|
-
}
|