@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,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { SettingBlock } from "@/components/Settings/SettingRow";
|
|
3
4
|
import { ModeChooser } from "@/components/Report/ModeChooser";
|
|
4
5
|
import { useSetWorkspaceMode, useWorkspaceMode } from "@/data/reports";
|
|
5
6
|
|
|
@@ -9,14 +10,13 @@ export function WorkspaceModeForm({ workspaceId }: { workspaceId: string }) {
|
|
|
9
10
|
const set = useSetWorkspaceMode(workspaceId);
|
|
10
11
|
if (!mode) return null;
|
|
11
12
|
return (
|
|
12
|
-
<
|
|
13
|
-
<h4 className="text-sm font-medium">Operating mode</h4>
|
|
13
|
+
<SettingBlock id={`mode-${workspaceId}`} label="Operating mode">
|
|
14
14
|
<ModeChooser
|
|
15
15
|
mode={mode}
|
|
16
16
|
onChange={(m) => set.mutate(m)}
|
|
17
17
|
disabled={set.isPending}
|
|
18
18
|
note="New tasks start in this mode; any task can change its own."
|
|
19
19
|
/>
|
|
20
|
-
</
|
|
20
|
+
</SettingBlock>
|
|
21
21
|
);
|
|
22
22
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useState } from "react";
|
|
4
|
+
import { PickerSetting } from "@/components/Settings/PickerSetting";
|
|
5
|
+
import { SettingBlock } from "@/components/Settings/SettingRow";
|
|
4
6
|
import { ModelSelect } from "@/components/Models/ModelSelect";
|
|
5
7
|
import {
|
|
6
8
|
useModelsQuery,
|
|
@@ -26,46 +28,44 @@ export function WorkspaceModelsForm({ workspaceId }: { workspaceId: string }) {
|
|
|
26
28
|
const set = Object.keys(roles).length;
|
|
27
29
|
|
|
28
30
|
return (
|
|
29
|
-
|
|
30
|
-
<
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
31
|
+
<>
|
|
32
|
+
<SettingBlock id={`models-${workspaceId}`} label="Models">
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
className="text-muted-foreground hover:text-foreground flex min-h-11 w-full items-center text-left text-xs md:min-h-0"
|
|
36
|
+
onClick={() => setOpen((o) => !o)}
|
|
37
|
+
aria-expanded={open}
|
|
38
|
+
>
|
|
39
|
+
{set ? `${set} set for this workspace` : "As Settings › Models"}{" "}
|
|
40
|
+
{open ? "▴" : "▾"}
|
|
41
|
+
</button>
|
|
42
|
+
</SettingBlock>
|
|
43
|
+
{open &&
|
|
44
|
+
WORKSPACE_ROLES.map((role) => (
|
|
45
|
+
<PickerSetting<string | null>
|
|
46
|
+
key={role}
|
|
47
|
+
id={`ws-${workspaceId}-${role}`}
|
|
48
|
+
label={ROLE_LABELS[role].label}
|
|
49
|
+
value={roles[role] ?? null}
|
|
50
|
+
defaultValue={null}
|
|
51
|
+
onSave={(ref) => {
|
|
52
|
+
const next = { ...roles };
|
|
53
|
+
if (ref) next[role] = ref;
|
|
54
|
+
else delete next[role];
|
|
55
|
+
return update.mutateAsync({ roles: next });
|
|
56
|
+
}}
|
|
57
|
+
render={({ value, commit }) => (
|
|
49
58
|
<ModelSelect
|
|
50
59
|
id={`ws-${workspaceId}-${role}`}
|
|
51
|
-
value={
|
|
60
|
+
value={value}
|
|
52
61
|
effective={models.data?.effective}
|
|
53
62
|
inheritLabel={`As Settings (${models.data?.effective.roles[role] ?? "…"})`}
|
|
54
|
-
disabled={
|
|
55
|
-
onChange={
|
|
56
|
-
const next = { ...roles };
|
|
57
|
-
if (ref) next[role] = ref;
|
|
58
|
-
else delete next[role];
|
|
59
|
-
update.mutate({ roles: next });
|
|
60
|
-
}}
|
|
63
|
+
disabled={!models.data}
|
|
64
|
+
onChange={commit}
|
|
61
65
|
/>
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
{update.isError && (
|
|
67
|
-
<p className="text-destructive text-xs">{update.error.message}</p>
|
|
68
|
-
)}
|
|
69
|
-
</div>
|
|
66
|
+
)}
|
|
67
|
+
/>
|
|
68
|
+
))}
|
|
69
|
+
</>
|
|
70
70
|
);
|
|
71
71
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useUpdateWorkspace } from "@/data/workspaces";
|
|
4
|
+
import type { Workspace } from "@/lib/db";
|
|
5
|
+
import {
|
|
6
|
+
NumberSetting,
|
|
7
|
+
SwitchSetting,
|
|
8
|
+
} from "@/components/Settings/settings-fields";
|
|
9
|
+
import { SettingSection } from "@/components/Settings/SettingRow";
|
|
10
|
+
import { SETTING_DEFAULTS } from "@/lib/settings/defaults";
|
|
11
|
+
import { HqFolderForm } from "./HqFolderForm";
|
|
12
|
+
import { McpServersForm } from "./McpServersForm";
|
|
13
|
+
import { ProjectTiersForm } from "./ProjectTiersForm";
|
|
14
|
+
import { RulesForm } from "./RulesForm";
|
|
15
|
+
import { WorkspaceModeForm } from "./WorkspaceModeForm";
|
|
16
|
+
import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
|
|
17
|
+
|
|
18
|
+
// One workspace's settings, in titled groups.
|
|
19
|
+
export function WorkspacePanel({ workspace: w }: { workspace: Workspace }) {
|
|
20
|
+
const update = useUpdateWorkspace();
|
|
21
|
+
return (
|
|
22
|
+
<div aria-label={`${w.name} settings`} role="group">
|
|
23
|
+
<SettingSection
|
|
24
|
+
title="Limits & mode"
|
|
25
|
+
help="Tasks over the limit wait in Queued and start by themselves, in order, when a running one finishes."
|
|
26
|
+
>
|
|
27
|
+
<NumberSetting
|
|
28
|
+
id={`task-limit-${w.id}`}
|
|
29
|
+
label="Tasks at a time"
|
|
30
|
+
value={w.max_running_tasks ?? 0}
|
|
31
|
+
defaultValue={0}
|
|
32
|
+
onSave={(n) =>
|
|
33
|
+
update.mutateAsync({
|
|
34
|
+
id: w.id,
|
|
35
|
+
maxRunningTasks: n === 0 ? null : n,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
min={1}
|
|
39
|
+
integer
|
|
40
|
+
unit="at a time"
|
|
41
|
+
optional={{ off: 0, on: SETTING_DEFAULTS.taskLimitOn }}
|
|
42
|
+
/>
|
|
43
|
+
<WorkspaceModeForm workspaceId={w.id} />
|
|
44
|
+
</SettingSection>
|
|
45
|
+
<SettingSection
|
|
46
|
+
title="Orchestrator"
|
|
47
|
+
help="What this workspace's orchestrator may do, which models it runs on, and where its session logs go."
|
|
48
|
+
>
|
|
49
|
+
<SwitchSetting
|
|
50
|
+
id={`full-access-${w.id}`}
|
|
51
|
+
label="Full access"
|
|
52
|
+
help="Its shell and file writes aren't checked. Applies when its conversation next restarts."
|
|
53
|
+
checked={w.orch_full_access}
|
|
54
|
+
confirm="Let this orchestrator act without approval? It can push, commit, edit any repository or file on this machine, merge a PR directly, and edit .claude files. Off: the deny list, write limits and merge-only-through-the-gates apply."
|
|
55
|
+
confirmLabel="Turn on full access"
|
|
56
|
+
onSave={(on) => update.mutateAsync({ id: w.id, orchFullAccess: on })}
|
|
57
|
+
/>
|
|
58
|
+
<SwitchSetting
|
|
59
|
+
id={`wake-${w.id}`}
|
|
60
|
+
label="Wake tasks on CI failures and reviews"
|
|
61
|
+
help="A task's agent is messaged once when a check fails on its PR, and when someone reviews or comments."
|
|
62
|
+
checked={w.wake_tasks_on_pr}
|
|
63
|
+
onSave={(on) => update.mutateAsync({ id: w.id, wakeTasksOnPR: on })}
|
|
64
|
+
/>
|
|
65
|
+
<WorkspaceModelsForm workspaceId={w.id} />
|
|
66
|
+
<HqFolderForm workspaceId={w.id} />
|
|
67
|
+
</SettingSection>
|
|
68
|
+
<RulesForm workspaceId={w.id} />
|
|
69
|
+
<McpServersForm workspace={w} />
|
|
70
|
+
<ProjectTiersForm workspaceId={w.id} />
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Select,
|
|
5
|
+
SelectContent,
|
|
6
|
+
SelectItem,
|
|
7
|
+
SelectTrigger,
|
|
8
|
+
SelectValue,
|
|
9
|
+
} from "@/components/ui/select";
|
|
10
|
+
import type { Workspace } from "@/lib/db";
|
|
11
|
+
|
|
12
|
+
// Which workspace's settings the page shows.
|
|
13
|
+
export function WorkspacePicker({
|
|
14
|
+
workspaces,
|
|
15
|
+
value,
|
|
16
|
+
onChange,
|
|
17
|
+
}: {
|
|
18
|
+
workspaces: Workspace[];
|
|
19
|
+
value: string;
|
|
20
|
+
onChange: (id: string) => void;
|
|
21
|
+
}) {
|
|
22
|
+
return (
|
|
23
|
+
<div className="mb-4 flex items-center gap-3">
|
|
24
|
+
<label htmlFor="settings-workspace" className="text-sm font-medium">
|
|
25
|
+
Workspace
|
|
26
|
+
</label>
|
|
27
|
+
<Select value={value} onValueChange={onChange}>
|
|
28
|
+
<SelectTrigger
|
|
29
|
+
id="settings-workspace"
|
|
30
|
+
className="h-11 min-w-0 flex-1 text-base sm:max-w-xs md:h-9 md:text-sm"
|
|
31
|
+
>
|
|
32
|
+
<SelectValue />
|
|
33
|
+
</SelectTrigger>
|
|
34
|
+
<SelectContent>
|
|
35
|
+
{workspaces.map((w) => (
|
|
36
|
+
<SelectItem key={w.id} value={w.id}>
|
|
37
|
+
{w.name}
|
|
38
|
+
</SelectItem>
|
|
39
|
+
))}
|
|
40
|
+
</SelectContent>
|
|
41
|
+
</Select>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
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, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
for (const key of Object.getOwnPropertyNames(window))
|
|
7
|
+
if (
|
|
8
|
+
/^(HTML|SVG|Event|CustomEvent|FocusEvent|DocumentFragment|Node|Element|Mutation)/.test(
|
|
9
|
+
key
|
|
10
|
+
) &&
|
|
11
|
+
!(key in globalThis)
|
|
12
|
+
)
|
|
13
|
+
Object.defineProperty(globalThis, key, {
|
|
14
|
+
value: (window as unknown as Record<string, unknown>)[key],
|
|
15
|
+
configurable: true,
|
|
16
|
+
writable: true,
|
|
17
|
+
});
|
|
18
|
+
(
|
|
19
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
20
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
21
|
+
|
|
22
|
+
const ws = (id: string, name: string, limit: number | null) => ({
|
|
23
|
+
id,
|
|
24
|
+
name,
|
|
25
|
+
max_running_tasks: limit,
|
|
26
|
+
orch_full_access: false,
|
|
27
|
+
wake_tasks_on_pr: true,
|
|
28
|
+
orch_mcp_servers: [],
|
|
29
|
+
});
|
|
30
|
+
const state = vi.hoisted(() => ({
|
|
31
|
+
workspaces: [] as unknown[],
|
|
32
|
+
projects: [] as unknown[],
|
|
33
|
+
sidebar: null as unknown,
|
|
34
|
+
update: vi.fn(),
|
|
35
|
+
}));
|
|
36
|
+
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
|
|
37
|
+
vi.mock("@/data/workspaces", () => ({
|
|
38
|
+
useWorkspacesQuery: () => ({ isPending: false, data: state.workspaces }),
|
|
39
|
+
useUpdateWorkspace: () => ({ mutateAsync: state.update }),
|
|
40
|
+
}));
|
|
41
|
+
vi.mock("@/data/projects", () => ({
|
|
42
|
+
useProjectsQuery: () => ({ data: state.projects }),
|
|
43
|
+
}));
|
|
44
|
+
vi.mock("@/hooks/useSelectedWorkspace", () => ({
|
|
45
|
+
useSelectedWorkspace: () => ({ workspace: state.sidebar }),
|
|
46
|
+
}));
|
|
47
|
+
vi.mock("@/components/Settings/AppWideLimits", () => ({
|
|
48
|
+
AppWideLimits: () => null,
|
|
49
|
+
}));
|
|
50
|
+
for (const name of [
|
|
51
|
+
"RulesForm",
|
|
52
|
+
"McpServersForm",
|
|
53
|
+
"HqFolderForm",
|
|
54
|
+
"ProjectTiersForm",
|
|
55
|
+
"WorkspaceModeForm",
|
|
56
|
+
"WorkspaceModelsForm",
|
|
57
|
+
])
|
|
58
|
+
vi.doMock(`./${name}`, () => ({
|
|
59
|
+
[name]: () => null,
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
const { WorkspaceSettings } = await import("./WorkspaceSettings");
|
|
63
|
+
const { SettingsQueryContext } =
|
|
64
|
+
await import("@/components/Settings/SettingRow");
|
|
65
|
+
|
|
66
|
+
let root: Root;
|
|
67
|
+
let host: HTMLDivElement;
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
localStorage.clear();
|
|
70
|
+
state.update.mockReset().mockResolvedValue(undefined);
|
|
71
|
+
state.workspaces = [ws("w1", "Alpha", null), ws("w2", "Beta", 2)];
|
|
72
|
+
state.projects = [
|
|
73
|
+
{ id: "p1", name: "beta-api", workspace_id: "w2", is_uncategorized: false },
|
|
74
|
+
];
|
|
75
|
+
state.sidebar = null;
|
|
76
|
+
host = document.createElement("div");
|
|
77
|
+
document.body.appendChild(host);
|
|
78
|
+
root = createRoot(host);
|
|
79
|
+
});
|
|
80
|
+
afterEach(async () => {
|
|
81
|
+
await act(async () => root.unmount());
|
|
82
|
+
host.remove();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const render = (query = "") =>
|
|
86
|
+
act(async () =>
|
|
87
|
+
root.render(
|
|
88
|
+
createElement(
|
|
89
|
+
SettingsQueryContext.Provider,
|
|
90
|
+
{ value: query },
|
|
91
|
+
createElement(WorkspaceSettings)
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
);
|
|
95
|
+
const picker = () => host.querySelector<HTMLElement>("#settings-workspace")!;
|
|
96
|
+
const titles = () => [...host.querySelectorAll("h3")].map((h) => h.textContent);
|
|
97
|
+
const limit = () =>
|
|
98
|
+
host.querySelector<HTMLInputElement>("#task-limit-w1, #task-limit-w2")!;
|
|
99
|
+
|
|
100
|
+
describe("WorkspaceSettings", () => {
|
|
101
|
+
it("shows one workspace at a time, in titled groups", async () => {
|
|
102
|
+
await render();
|
|
103
|
+
expect(host.querySelectorAll('[role="group"]')).toHaveLength(1);
|
|
104
|
+
expect(picker().textContent).toBe("Alpha");
|
|
105
|
+
expect(titles()).toEqual(
|
|
106
|
+
expect.arrayContaining(["Limits & mode", "Orchestrator"])
|
|
107
|
+
);
|
|
108
|
+
expect(host.textContent).not.toContain("Running task limit:");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("preselects the sidebar's workspace", async () => {
|
|
112
|
+
state.sidebar = state.workspaces[1];
|
|
113
|
+
await render();
|
|
114
|
+
expect(picker().textContent).toBe("Beta");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("remembers the choice", async () => {
|
|
118
|
+
localStorage.setItem("agentOS-settings-workspace", "w2");
|
|
119
|
+
await render();
|
|
120
|
+
expect(picker().textContent).toBe("Beta");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("greys the number while the limit is off, keeping its value", async () => {
|
|
124
|
+
await render();
|
|
125
|
+
expect(limit().id).toBe("task-limit-w1");
|
|
126
|
+
expect(limit().disabled).toBe(true);
|
|
127
|
+
expect(limit().value).toBe("3");
|
|
128
|
+
expect(host.textContent).not.toContain("Default");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("enables the number when a limit is set, and hints the default", async () => {
|
|
132
|
+
localStorage.setItem("agentOS-settings-workspace", "w2");
|
|
133
|
+
await render();
|
|
134
|
+
expect(limit().disabled).toBe(false);
|
|
135
|
+
expect(limit().value).toBe("2");
|
|
136
|
+
expect(host.textContent).toContain("Default: off");
|
|
137
|
+
expect(
|
|
138
|
+
[...host.querySelectorAll("button")].some(
|
|
139
|
+
(b) => b.textContent === "Reset"
|
|
140
|
+
)
|
|
141
|
+
).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("a search matching only another workspace's project switches to it", async () => {
|
|
145
|
+
await render("beta-api");
|
|
146
|
+
expect(picker().textContent).toBe("Beta");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("a search matching rows on every page stays on the current workspace", async () => {
|
|
150
|
+
await render("mcp");
|
|
151
|
+
expect(picker().textContent).toBe("Alpha");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -1,28 +1,55 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
3
|
+
import { useContext, useState } from "react";
|
|
4
|
+
import { settingsUiActions } from "@/stores/settingsUi";
|
|
5
|
+
import { useWorkspacesQuery } from "@/data/workspaces";
|
|
6
|
+
import { useProjectsQuery } from "@/data/projects";
|
|
7
|
+
import { AppWideLimits } from "@/components/Settings/AppWideLimits";
|
|
8
|
+
import {
|
|
9
|
+
matchesQuery,
|
|
10
|
+
SettingsQueryContext,
|
|
11
|
+
} from "@/components/Settings/SettingRow";
|
|
12
|
+
import { WorkspacePanel } from "./WorkspacePanel";
|
|
13
|
+
import { WorkspacePicker } from "./WorkspacePicker";
|
|
14
|
+
import { useSettingsWorkspace } from "./useSettingsWorkspace";
|
|
15
15
|
|
|
16
|
-
//
|
|
16
|
+
// What every workspace's page has, for the search box to find.
|
|
17
|
+
const SHARED_ROWS =
|
|
18
|
+
"tasks at a time limit operating mode orchestrator full access wake ci failures reviews models hq folder session logs rules mcp servers projects production fast merge tier";
|
|
19
|
+
|
|
20
|
+
// Settings > Workspaces: one workspace's settings at a time.
|
|
17
21
|
export function WorkspaceSettings() {
|
|
18
22
|
const { data: workspaces, isPending, isError, error } = useWorkspacesQuery();
|
|
19
|
-
const
|
|
23
|
+
const { data: projects } = useProjectsQuery();
|
|
24
|
+
const query = useContext(SettingsQueryContext);
|
|
25
|
+
const { current, choose } = useSettingsWorkspace(workspaces ?? []);
|
|
26
|
+
// The search a hand-picked workspace overrides.
|
|
27
|
+
const [pinnedFor, setPinnedFor] = useState<string | null>(null);
|
|
28
|
+
|
|
29
|
+
// A search that matches only another workspace (its name, a project) shows
|
|
30
|
+
// that workspace; the shared rows are on every page, so they never move it.
|
|
31
|
+
const searchHit =
|
|
32
|
+
query.trim() &&
|
|
33
|
+
workspaces &&
|
|
34
|
+
current &&
|
|
35
|
+
pinnedFor !== query &&
|
|
36
|
+
!matchesQuery(query, SHARED_ROWS)
|
|
37
|
+
? workspaces.find((w) =>
|
|
38
|
+
matchesQuery(
|
|
39
|
+
query,
|
|
40
|
+
w.name,
|
|
41
|
+
...(projects ?? [])
|
|
42
|
+
.filter((p) => p.workspace_id === w.id)
|
|
43
|
+
.map((p) => p.name)
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
: undefined;
|
|
20
47
|
|
|
21
48
|
if (isPending)
|
|
22
49
|
return <div className="bg-muted/40 h-32 animate-pulse rounded-lg" />;
|
|
23
50
|
if (isError)
|
|
24
51
|
return <p className="text-destructive text-sm">{error.message}</p>;
|
|
25
|
-
if (!workspaces.length)
|
|
52
|
+
if (!workspaces.length || !current)
|
|
26
53
|
return (
|
|
27
54
|
<p className="text-muted-foreground text-sm">
|
|
28
55
|
No workspaces yet. Make one from the workspace menu at the top of the
|
|
@@ -30,46 +57,30 @@ export function WorkspaceSettings() {
|
|
|
30
57
|
</p>
|
|
31
58
|
);
|
|
32
59
|
|
|
60
|
+
const shown = searchHit ?? current;
|
|
33
61
|
return (
|
|
34
|
-
<div
|
|
62
|
+
<div>
|
|
63
|
+
<WorkspacePicker
|
|
64
|
+
workspaces={workspaces}
|
|
65
|
+
value={shown.id}
|
|
66
|
+
onChange={(id) => {
|
|
67
|
+
setPinnedFor(query);
|
|
68
|
+
choose(id);
|
|
69
|
+
}}
|
|
70
|
+
/>
|
|
71
|
+
<WorkspacePanel key={shown.id} workspace={shown} />
|
|
72
|
+
<AppWideLimits />
|
|
35
73
|
<p className="text-muted-foreground text-sm">
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
key={w.id}
|
|
42
|
-
aria-label={w.name}
|
|
43
|
-
className="bg-muted/40 space-y-3 rounded-lg px-3 py-3"
|
|
74
|
+
Fresh starts and compaction are app-wide:{" "}
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
className="text-primary -my-3 min-h-11 underline-offset-2 hover:underline"
|
|
78
|
+
onClick={() => settingsUiActions.show("context")}
|
|
44
79
|
>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
/>
|
|
50
|
-
<WorkspaceModeForm workspaceId={w.id} />
|
|
51
|
-
<RulesForm workspaceId={w.id} />
|
|
52
|
-
<WorkspaceModelsForm workspaceId={w.id} />
|
|
53
|
-
<McpServersForm workspace={w} />
|
|
54
|
-
<HqFolderForm workspaceId={w.id} />
|
|
55
|
-
<ProjectTiersForm workspaceId={w.id} />
|
|
56
|
-
<SettingToggle
|
|
57
|
-
title="Wake tasks on CI failures and reviews"
|
|
58
|
-
detail="A task's agent is messaged once when a check fails on its PR's head, with the log's tail, and when someone reviews or comments on it."
|
|
59
|
-
checked={w.wake_tasks_on_pr}
|
|
60
|
-
onChange={(on) => update.mutate({ id: w.id, wakeTasksOnPR: on })}
|
|
61
|
-
/>
|
|
62
|
-
<SettingToggle
|
|
63
|
-
title="Orchestrator full access"
|
|
64
|
-
detail="Its shell and file writes aren't checked: it can push, commit, edit any repository or file on this machine, and merge a PR directly, and edit .claude files (skills, settings, hooks). Applies when its conversation next restarts. Off: the deny list, write limits, quick-change cap and merge-only-through-the-gates apply."
|
|
65
|
-
checked={w.orch_full_access}
|
|
66
|
-
onChange={(on) => update.mutate({ id: w.id, orchFullAccess: on })}
|
|
67
|
-
/>
|
|
68
|
-
</section>
|
|
69
|
-
))}
|
|
70
|
-
<FreshStartForm />
|
|
71
|
-
<StallForm />
|
|
72
|
-
<AwayWindowForm />
|
|
80
|
+
open Context
|
|
81
|
+
</button>
|
|
82
|
+
.
|
|
83
|
+
</p>
|
|
73
84
|
</div>
|
|
74
85
|
);
|
|
75
86
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
4
|
+
import type { Workspace } from "@/lib/db";
|
|
5
|
+
import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
|
|
6
|
+
|
|
7
|
+
const KEY = "agentOS-settings-workspace";
|
|
8
|
+
|
|
9
|
+
const listeners = new Set<() => void>();
|
|
10
|
+
|
|
11
|
+
function subscribe(fn: () => void) {
|
|
12
|
+
listeners.add(fn);
|
|
13
|
+
window.addEventListener("storage", fn);
|
|
14
|
+
return () => {
|
|
15
|
+
listeners.delete(fn);
|
|
16
|
+
window.removeEventListener("storage", fn);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function read(): string | null {
|
|
21
|
+
try {
|
|
22
|
+
return localStorage.getItem(KEY);
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Which workspace Settings > Workspaces shows: the one picked here last,
|
|
29
|
+
// else the sidebar's, else the first. A remembered one since deleted falls
|
|
30
|
+
// through.
|
|
31
|
+
export function useSettingsWorkspace(workspaces: Workspace[]) {
|
|
32
|
+
const { workspace: sidebar } = useSelectedWorkspace();
|
|
33
|
+
const picked = useSyncExternalStore(subscribe, read, () => null);
|
|
34
|
+
const choose = useCallback((id: string) => {
|
|
35
|
+
try {
|
|
36
|
+
localStorage.setItem(KEY, id);
|
|
37
|
+
} catch {
|
|
38
|
+
// Not remembered; the choice still holds for this visit.
|
|
39
|
+
}
|
|
40
|
+
listeners.forEach((fn) => fn());
|
|
41
|
+
}, []);
|
|
42
|
+
const current =
|
|
43
|
+
workspaces.find((w) => w.id === picked) ??
|
|
44
|
+
workspaces.find((w) => w.id === sidebar?.id) ??
|
|
45
|
+
workspaces[0];
|
|
46
|
+
return { current, choose };
|
|
47
|
+
}
|
package/content/whats-new.json
CHANGED
|
@@ -1969,6 +1969,46 @@
|
|
|
1969
1969
|
"area": "AgentOS",
|
|
1970
1970
|
"text": "Forking from a selection is tidier: the fork pill can be dismissed while keeping your quote and text, and started forks show a one-line marker and what they're doing.",
|
|
1971
1971
|
"url": "https://github.com/saadnvd1/agent-os/pull/561"
|
|
1972
|
+
},
|
|
1973
|
+
{
|
|
1974
|
+
"id": 562,
|
|
1975
|
+
"date": "2026-11-02",
|
|
1976
|
+
"kind": "improved",
|
|
1977
|
+
"area": "Settings",
|
|
1978
|
+
"text": "Settings now save as you change them, with a clear \"Saved\" note, undo and reset, and Context settings have their own page.",
|
|
1979
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/562"
|
|
1980
|
+
},
|
|
1981
|
+
{
|
|
1982
|
+
"id": 563,
|
|
1983
|
+
"date": "2026-11-02",
|
|
1984
|
+
"kind": "improved",
|
|
1985
|
+
"area": "Settings",
|
|
1986
|
+
"text": "More settings now save as you change them, and revoking a passkey or link asks inline instead of with a browser pop-up.",
|
|
1987
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/563"
|
|
1988
|
+
},
|
|
1989
|
+
{
|
|
1990
|
+
"id": 564,
|
|
1991
|
+
"date": "2026-11-02",
|
|
1992
|
+
"kind": "fixed",
|
|
1993
|
+
"area": "Updates",
|
|
1994
|
+
"text": "Updating AgentOS no longer rolls back a good update when the machine's automatic deploy restarts the server at the same moment.",
|
|
1995
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/564"
|
|
1996
|
+
},
|
|
1997
|
+
{
|
|
1998
|
+
"id": 565,
|
|
1999
|
+
"date": "2026-11-02",
|
|
2000
|
+
"kind": "improved",
|
|
2001
|
+
"area": "Settings",
|
|
2002
|
+
"text": "The Workspaces settings page now shows one workspace at a time, in clear groups, with rules you can read in full and edit in place.",
|
|
2003
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/565"
|
|
2004
|
+
},
|
|
2005
|
+
{
|
|
2006
|
+
"id": 568,
|
|
2007
|
+
"date": "2026-11-02",
|
|
2008
|
+
"kind": "fixed",
|
|
2009
|
+
"area": "Sidebar",
|
|
2010
|
+
"text": "Renaming a session for the first time no longer closes the name box before you can type.",
|
|
2011
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/568"
|
|
1972
2012
|
}
|
|
1973
2013
|
]
|
|
1974
2014
|
}
|
|
@@ -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 }),
|