@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.
- package/app/api/fresh-start/route.test.ts +33 -0
- package/app/api/fresh-start/route.ts +3 -0
- package/app/api/health/route.ts +4 -2
- 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/SessionList/GroupShelves.tsx +8 -3
- package/components/SessionList/OrchestratorPinRow.tsx +2 -2
- package/components/SessionList/RowParts.tsx +39 -35
- package/components/SessionList/RowRename.test.ts +86 -17
- package/components/SessionList/RowShell.tsx +2 -5
- package/components/SessionList/SessionRow.tsx +0 -1
- 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 +168 -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 +428 -0
- package/components/Shares/SharedLinksPanel.tsx +62 -53
- package/components/Workspaces/HqFolderForm.tsx +22 -53
- package/components/Workspaces/McpServersForm.tsx +21 -28
- package/components/Workspaces/ProjectTiersForm.tsx +28 -13
- package/components/Workspaces/RuleEditor.tsx +86 -0
- package/components/Workspaces/RulesForm.test.ts +154 -0
- package/components/Workspaces/RulesForm.tsx +116 -117
- package/components/Workspaces/WorkspaceModeForm.tsx +3 -3
- package/components/Workspaces/WorkspaceModelsForm.tsx +36 -36
- package/components/Workspaces/WorkspacePanel.tsx +73 -0
- package/components/Workspaces/WorkspacePicker.tsx +44 -0
- package/components/Workspaces/WorkspaceSettings.test.ts +153 -0
- package/components/Workspaces/WorkspaceSettings.tsx +63 -52
- package/components/Workspaces/useSettingsWorkspace.ts +47 -0
- package/content/whats-new.json +40 -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/deploy.ts +34 -7
- 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/models/plan-fresh.ts +61 -0
- package/lib/models/plan-step.ts +40 -24
- package/lib/orchestrator/scope.ts +10 -0
- package/lib/settings/defaults.ts +27 -0
- package/lib/settings/parse.ts +17 -0
- package/lib/settings-url.ts +1 -0
- package/lib/updates/banner.ts +2 -0
- package/lib/updates/run.ts +9 -0
- package/lib/updates/view.ts +28 -6
- package/lib/worktree-deps/paths.ts +9 -4
- package/lib/worktree-sweep/limits.ts +3 -0
- package/lib/worktree-sweep/settings.ts +4 -2
- package/lib/worktrees.ts +3 -0
- package/package.json +1 -1
- package/scripts/autodeploy +11 -0
- package/scripts/autodeploy.test.ts +30 -0
- package/scripts/lib/update-verify.mjs +11 -0
- package/scripts/update-run.mjs +29 -0
- package/scripts/update-run.test.ts +62 -4
- 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
|
@@ -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 {
|
|
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
|
-
<
|
|
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
|
-
<
|
|
66
|
-
<
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
71
|
+
settings.telegram.tokenSet
|
|
72
|
+
? "Connected; paste to replace"
|
|
73
|
+
: "123:ABC…"
|
|
77
74
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
</
|
|
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
|
-
</
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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("
|
|
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("
|
|
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(
|
|
164
|
+
keepRenameFocus(e);
|
|
98
165
|
expect(e.preventDefault).not.toHaveBeenCalled();
|
|
99
|
-
|
|
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
|
|
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
|
|
133
|
+
onCloseAutoFocus={keepRenameFocus}
|
|
137
134
|
>
|
|
138
135
|
{menu("context")}
|
|
139
136
|
</CM.ContextMenuContent>
|
|
@@ -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(() => ({
|
|
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: () => ({
|
|
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.
|
|
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.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
);
|
|
92
|
+
expect(state.mutateAsync).toHaveBeenCalledWith({
|
|
93
|
+
id: "learning-cards",
|
|
94
|
+
on: true,
|
|
95
|
+
});
|
|
94
96
|
});
|
|
95
97
|
});
|