@saadnvd1/agent-os 0.3.845 → 0.3.851

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 (80) hide show
  1. package/README.md +6 -0
  2. package/app/api/approvals/route.ts +5 -1
  3. package/app/api/health/config/route.ts +2 -0
  4. package/app/api/updates/run/route.ts +7 -3
  5. package/app/globals.css +6 -0
  6. package/components/Audits/AuditsPanel.tsx +42 -35
  7. package/components/Chat/quote-selection.test.ts +6 -0
  8. package/components/Chat/quote-selection.ts +13 -10
  9. package/components/Devices/ConnectCard.tsx +52 -56
  10. package/components/Devices/DeviceGroupRow.tsx +65 -0
  11. package/components/Devices/DeviceRow.tsx +2 -2
  12. package/components/Devices/DevicesPanel.tsx +63 -49
  13. package/components/Devices/NetworkSection.tsx +29 -26
  14. package/components/Devices/TailscaleCard.tsx +9 -10
  15. package/components/Devices/group.test.ts +66 -0
  16. package/components/Devices/group.ts +67 -0
  17. package/components/Health/HealthPanel.tsx +5 -81
  18. package/components/Health/MachineChecks.tsx +20 -0
  19. package/components/Health/SweepRuns.tsx +46 -51
  20. package/components/Health/Thresholds.tsx +50 -0
  21. package/components/Merge/GlobalMergeSection.tsx +5 -6
  22. package/components/Merge/MergeApprovalToggle.tsx +20 -14
  23. package/components/Merge/MergeSettingsFields.tsx +0 -2
  24. package/components/Merge/WorktreeSweepSection.tsx +15 -34
  25. package/components/Models/AgentRoleCard.tsx +7 -7
  26. package/components/Models/ModelSelect.tsx +14 -3
  27. package/components/Models/ModelsSection.tsx +42 -37
  28. package/components/Models/ProviderCard.tsx +1 -1
  29. package/components/Models/RoleModelSelect.tsx +15 -3
  30. package/components/Models/SubagentModels.tsx +60 -45
  31. package/components/PhoneNotify/PhoneNotifyForm.tsx +6 -13
  32. package/components/Recipes/RecipesPanel.tsx +20 -9
  33. package/components/Recipes/RunHistory.tsx +1 -1
  34. package/components/Schedules/SchedulesPanel.tsx +12 -10
  35. package/components/Settings/AppWideLimits.tsx +2 -2
  36. package/components/Settings/{NoApprovalsSection.test.ts → ApprovalsSection.test.ts} +61 -22
  37. package/components/Settings/ApprovalsSection.tsx +93 -0
  38. package/components/Settings/ExperimentsSection.test.ts +20 -4
  39. package/components/Settings/ExperimentsSection.tsx +54 -34
  40. package/components/Settings/GeneralSection.tsx +39 -0
  41. package/components/Settings/ListSection.test.ts +85 -0
  42. package/components/Settings/ListSection.tsx +132 -0
  43. package/components/Settings/NotificationsSection.tsx +31 -29
  44. package/components/Settings/PickerSetting.tsx +5 -1
  45. package/components/Settings/SettingRow.tsx +13 -8
  46. package/components/Settings/SettingsDialog.test.ts +31 -4
  47. package/components/Settings/SettingsDialog.tsx +22 -17
  48. package/components/Settings/ValueLabel.tsx +31 -0
  49. package/components/Settings/settings-fields.test.ts +81 -2
  50. package/components/Settings/settings-fields.tsx +27 -27
  51. package/components/Shares/SharedLinksPanel.tsx +11 -14
  52. package/components/Updates/AutoUpdates.tsx +12 -8
  53. package/components/Updates/UpdateActions.tsx +18 -3
  54. package/components/Updates/UpdateToast.test.ts +17 -0
  55. package/components/Updates/UpdateToast.tsx +15 -5
  56. package/components/Updates/UpdatesSection.tsx +27 -18
  57. package/components/Workspaces/WorkspaceSettings.test.ts +5 -5
  58. package/components/Workspaces/WorkspaceSettings.tsx +0 -2
  59. package/components/ui/switch.tsx +1 -1
  60. package/content/whats-new.json +24 -0
  61. package/data/approvals/index.ts +2 -2
  62. package/data/health/index.ts +3 -0
  63. package/hooks/useAutoSave.ts +18 -5
  64. package/hooks/useReloadWhenUpdated.ts +9 -0
  65. package/hooks/useSettingsUrl.ts +6 -3
  66. package/lib/approvals/describe.ts +64 -0
  67. package/lib/health/config.ts +70 -17
  68. package/lib/models/providers.ts +1 -1
  69. package/lib/recipes/settle.ts +22 -0
  70. package/lib/settings-url.ts +14 -2
  71. package/lib/updates/banner.ts +12 -2
  72. package/lib/updates/request.ts +29 -0
  73. package/lib/updates/run.ts +24 -5
  74. package/lib/updates/view.ts +3 -0
  75. package/package.json +1 -1
  76. package/scripts/autodeploy +25 -3
  77. package/scripts/autodeploy.test.ts +47 -0
  78. package/components/Settings/NoApprovalsSection.tsx +0 -103
  79. package/components/Settings/SettingToggle.tsx +0 -36
  80. package/components/Settings/SetupSection.tsx +0 -46
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { canManageDevices } from "@/components/Devices";
4
- import { SettingToggle } from "@/components/Settings/SettingToggle";
4
+ import { SwitchSetting } from "@/components/Settings/settings-fields";
5
5
  import {
6
6
  useDevicesQuery,
7
7
  useNetworkQuery,
@@ -11,7 +11,12 @@ import {
11
11
  // Switching it off asks for a passkey (useUpdateNetwork), and only the
12
12
  // machine itself or the tailnet may change it, like the other access
13
13
  // switches.
14
- export function MergeApprovalToggle() {
14
+ export function MergeApprovalToggle({
15
+ overriddenBy,
16
+ }: {
17
+ // While No approvals is on this switch has no effect: say so, greyed.
18
+ overriddenBy?: boolean;
19
+ }) {
15
20
  const devices = useDevicesQuery(true);
16
21
  const { data, isPending, isError, error } = useNetworkQuery(true);
17
22
  const update = useUpdateNetwork();
@@ -23,17 +28,18 @@ export function MergeApprovalToggle() {
23
28
  return <p className="text-destructive text-sm">{error.message}</p>;
24
29
 
25
30
  return (
26
- <div className="space-y-1">
27
- <SettingToggle
28
- title="Require my approval to merge sensitive or large PRs"
29
- detail="PRs touching CI, deploys, secrets, build scripts, agent config or AgentOS's security code, or too big to review whole, wait for you to approve them with your passkey. Off, they merge through the usual gates, and a big PR is reviewed in parts."
30
- checked={data.mergeApprovals.on}
31
- locked={data.mergeApprovals.locked || !canManage}
32
- onChange={(mergeApprovals) => update.mutate({ mergeApprovals })}
33
- />
34
- {update.error && (
35
- <p className="text-destructive text-xs">{update.error.message}</p>
36
- )}
37
- </div>
31
+ <SwitchSetting
32
+ id="merge-approvals"
33
+ label="Require my approval to merge sensitive or large PRs"
34
+ help={
35
+ overriddenBy
36
+ ? "Not in effect while No approvals is on: every PR merges through the usual gates."
37
+ : "PRs touching CI, deploys, secrets, build scripts, agent config or AgentOS's security code, or too big to review whole, wait for you to approve them with your passkey. Off, they merge through the usual gates, and a big PR is reviewed in parts."
38
+ }
39
+ disabled={overriddenBy}
40
+ checked={data.mergeApprovals.on}
41
+ locked={data.mergeApprovals.locked || !canManage}
42
+ onSave={(mergeApprovals) => update.mutateAsync({ mergeApprovals })}
43
+ />
38
44
  );
39
45
  }
@@ -77,7 +77,6 @@ export function MergeSettingsFields({
77
77
  }
78
78
  options={[inheritOption(key, onOff(inherited[key])), ...TOGGLE]}
79
79
  defaultValue={INHERIT}
80
- hint=""
81
80
  onSave={(v) => set(key, v === INHERIT ? undefined : v === "on")}
82
81
  />
83
82
  );
@@ -102,7 +101,6 @@ export function MergeSettingsFields({
102
101
  })),
103
102
  ]}
104
103
  defaultValue={INHERIT}
105
- hint=""
106
104
  onSave={(v) =>
107
105
  set("method", v === INHERIT ? undefined : (v as MergeMethod))
108
106
  }
@@ -1,10 +1,10 @@
1
1
  "use client";
2
2
 
3
3
  import { useState } from "react";
4
+ import { NumberSetting } from "@/components/Settings/settings-fields";
5
+ import { SettingSection } from "@/components/Settings/SettingRow";
4
6
  import { Input } from "@/components/ui/input";
5
7
  import { useSetSweepDays, useWorktreeSweep } from "@/data/worktree-sweep";
6
- import { compactTimeAgo, fromSqliteTime } from "@/lib/session-meta";
7
- import { formatBytes } from "@/lib/share/view";
8
8
  import { parseSettingNumber } from "@/lib/settings/parse";
9
9
  import { MAX_SWEEP_DAYS } from "@/lib/worktree-sweep/limits";
10
10
 
@@ -66,19 +66,6 @@ function DaysInput({
66
66
  );
67
67
  }
68
68
 
69
- function LastRun() {
70
- const { data } = useWorktreeSweep();
71
- const last = data?.runs.find((r) => r.finishedAt && !r.dryRun);
72
- if (!last) return null;
73
- return (
74
- <p className="text-muted-foreground text-xs">
75
- Last run {compactTimeAgo(fromSqliteTime(last.finishedAt!))}: removed{" "}
76
- {last.removed}, {formatBytes(last.freedBytes)} freed, {last.skipped}{" "}
77
- skipped. Details in Health &gt; Clean-up.
78
- </p>
79
- );
80
- }
81
-
82
69
  // How long a settled worktree stays on disk. The branch and the conversation
83
70
  // stay; restoring the session rebuilds it.
84
71
  export function GlobalSweepSection() {
@@ -86,27 +73,25 @@ export function GlobalSweepSection() {
86
73
  const save = useSetSweepDays();
87
74
  return (
88
75
  <div className="space-y-2">
89
- <h3 className="text-sm font-medium">Worktree clean-up</h3>
90
- <p className="text-muted-foreground text-sm">
91
- Remove the worktree of a merged, dropped, done or archived session this
92
- many days after it settled, when nothing in it would be lost. 0 turns it
93
- off.
94
- </p>
95
76
  {isPending && (
96
77
  <div className="bg-muted/40 h-11 animate-pulse rounded-lg" />
97
78
  )}
98
79
  {isError && <p className="text-destructive text-sm">{error.message}</p>}
99
80
  {data && (
100
- <div className="flex items-center gap-3">
101
- <DaysInput
102
- key={data.days ?? "none"}
103
- value={data.days}
104
- placeholder={String(data.defaultDays)}
105
- disabled={save.isPending}
106
- onSave={(days) => save.mutate({ days })}
81
+ <SettingSection title="Worktree clean-up">
82
+ <NumberSetting
83
+ id="sweep-days"
84
+ label="Remove settled worktrees after"
85
+ help="A merged, dropped, done or archived session's worktree goes this long after it settled, when nothing in it would be lost."
86
+ value={data.days ?? data.defaultDays}
87
+ defaultValue={data.defaultDays}
88
+ min={1}
89
+ max={MAX_SWEEP_DAYS}
90
+ unit="days"
91
+ optional={{ off: 0, on: data.defaultDays }}
92
+ onSave={(days) => save.mutateAsync({ days })}
107
93
  />
108
- <span className="text-muted-foreground text-sm">days</span>
109
- </div>
94
+ </SettingSection>
110
95
  )}
111
96
  {data && data.overrides.length > 0 && (
112
97
  <p className="text-muted-foreground text-xs">
@@ -114,10 +99,6 @@ export function GlobalSweepSection() {
114
99
  {data.overrides.map((o) => `${o.name} (${o.days})`).join(", ")}
115
100
  </p>
116
101
  )}
117
- {save.isError && (
118
- <p className="text-destructive text-sm">{save.error.message}</p>
119
- )}
120
- <LastRun />
121
102
  </div>
122
103
  );
123
104
  }
@@ -32,9 +32,8 @@ export function AgentRoleCard({
32
32
  }) {
33
33
  const down = role.choice?.availability.state === "down";
34
34
  const analyst = role.id === "analyst";
35
- const label = analyst
36
- ? defaultAnalystLabel(role.default, [])
37
- : `Default (${role.default.split("/").slice(1).join("/")})`;
35
+ const modelId = (ref: string) => ref.split("/").slice(1).join("/");
36
+ const label = analyst ? defaultAnalystLabel(role.default, []) : "Default";
38
37
  const blockedBy = analyst ? analystBlock : undefined;
39
38
  const inheritLabel =
40
39
  scope === "settings"
@@ -60,10 +59,11 @@ export function AgentRoleCard({
60
59
  <RoleModelSelect
61
60
  id={`agent-role-${role.id}`}
62
61
  value={value}
63
- inheritLabel={
64
- value === null && role.binding
65
- ? `${inheritLabel} · ${role.binding.ref.split("/").slice(1).join("/")}`
66
- : inheritLabel
62
+ inheritLabel={inheritLabel}
63
+ inheritDetail={
64
+ scope === "settings" && role.modelRole === "advise" && !analyst
65
+ ? undefined
66
+ : modelId(role.binding?.ref ?? role.default)
67
67
  }
68
68
  blockedBy={blockedBy}
69
69
  onChange={commit}
@@ -9,6 +9,7 @@ import {
9
9
  SelectTrigger,
10
10
  SelectValue,
11
11
  } from "@/components/ui/select";
12
+ import { TWO_LINE_TRIGGER, ValueLabel } from "@/components/Settings/ValueLabel";
12
13
  import { modelChoices } from "@/data/models";
13
14
  import type { EffectiveModels } from "@/lib/models/config";
14
15
 
@@ -21,6 +22,7 @@ export function ModelSelect({
21
22
  value,
22
23
  effective,
23
24
  inheritLabel,
25
+ inheritDetail,
24
26
  disabled,
25
27
  onChange,
26
28
  }: {
@@ -28,6 +30,7 @@ export function ModelSelect({
28
30
  value: string | null;
29
31
  effective: EffectiveModels | undefined;
30
32
  inheritLabel?: string;
33
+ inheritDetail?: string;
31
34
  disabled?: boolean;
32
35
  onChange: (ref: string | null) => void;
33
36
  }) {
@@ -41,12 +44,20 @@ export function ModelSelect({
41
44
  onValueChange={(v) => onChange(v === INHERIT ? null : v)}
42
45
  disabled={disabled}
43
46
  >
44
- <SelectTrigger id={id} className="h-11 md:h-9">
45
- <SelectValue />
47
+ <SelectTrigger id={id} className={TWO_LINE_TRIGGER}>
48
+ <SelectValue>
49
+ {value === null && inheritLabel ? (
50
+ <ValueLabel label={inheritLabel} detail={inheritDetail} />
51
+ ) : (
52
+ (choices.find((c) => c.ref === value)?.label ?? value)
53
+ )}
54
+ </SelectValue>
46
55
  </SelectTrigger>
47
56
  <SelectContent>
48
57
  {inheritLabel && (
49
- <SelectItem value={INHERIT}>{inheritLabel}</SelectItem>
58
+ <SelectItem value={INHERIT}>
59
+ <ValueLabel label={inheritLabel} detail={inheritDetail} />
60
+ </SelectItem>
50
61
  )}
51
62
  {missing && <SelectItem value={value}>{value}</SelectItem>}
52
63
  {groups.map((g) => (
@@ -2,7 +2,10 @@
2
2
 
3
3
  import { Button } from "@/components/ui/button";
4
4
  import { PriceInput } from "./PriceInput";
5
- import { SettingToggle } from "@/components/Settings/SettingToggle";
5
+ import { PickerSetting } from "@/components/Settings/PickerSetting";
6
+ import { ScrollTable } from "@/components/Settings/ListSection";
7
+ import { SettingSection } from "@/components/Settings/SettingRow";
8
+ import { SwitchSetting } from "@/components/Settings/settings-fields";
6
9
  import { useModelsQuery, useUpdateModels } from "@/data/models";
7
10
  import type { ModelSettings } from "@/lib/models/config";
8
11
  import {
@@ -63,42 +66,42 @@ export function ModelsSection() {
63
66
  and Supervisor are in Agent roles. A workspace can set its own
64
67
  (Settings › Workspaces), and a task its own when it starts or resumes.
65
68
  </p>
66
- <ul className="space-y-2">
69
+ <SettingSection>
67
70
  {INTERNAL_MODEL_ROLES(MODEL_ROLES).map((role) => (
68
- <li
71
+ <PickerSetting<string | null>
69
72
  key={role}
70
- className="bg-muted/40 space-y-1.5 rounded-lg px-3 py-2.5"
71
- >
72
- <label htmlFor={`role-${role}`} className="block">
73
- <span className="text-sm font-medium">
74
- {ROLE_LABELS[role].label}
75
- </span>
76
- <span className="text-muted-foreground block text-xs">
77
- {ROLE_LABELS[role].hint}
78
- </span>
79
- </label>
80
- <ModelSelect
81
- id={`role-${role}`}
82
- value={saved.roles?.[role] ?? null}
83
- effective={effective}
84
- inheritLabel={`Default (${defaults[role].split("/").slice(1).join("/")})`}
85
- disabled={update.isPending}
86
- onChange={(ref) => {
87
- const roles = { ...saved.roles };
88
- if (ref) roles[role] = ref;
89
- else delete roles[role];
90
- save({ roles });
91
- }}
92
- />
93
- </li>
73
+ id={`role-${role}`}
74
+ label={ROLE_LABELS[role].label}
75
+ help={ROLE_LABELS[role].hint}
76
+ value={saved.roles?.[role] ?? null}
77
+ defaultValue={null}
78
+ resetLabel="default"
79
+ onSave={(ref) => {
80
+ const roles = { ...saved.roles };
81
+ if (ref) roles[role] = ref;
82
+ else delete roles[role];
83
+ return update.mutateAsync({ ...saved, roles });
84
+ }}
85
+ render={({ value, commit }) => (
86
+ <ModelSelect
87
+ id={`role-${role}`}
88
+ value={value}
89
+ effective={effective}
90
+ inheritLabel="Default"
91
+ inheritDetail={defaults[role].split("/").slice(1).join("/")}
92
+ onChange={commit}
93
+ />
94
+ )}
95
+ />
94
96
  ))}
95
- </ul>
96
- <SettingToggle
97
- title="Tasks plan first"
98
- detail="A chat task's first turn writes its plan to .agentos/plan.md on the planning model; the implementation model then follows it in the same conversation."
99
- checked={effective.planStep}
100
- onChange={(planStep) => save({ planStep })}
101
- />
97
+ <SwitchSetting
98
+ id="plan-step"
99
+ label="Tasks plan first"
100
+ help="A chat task's first turn writes its plan on the planning model; the implementation model then follows it in the same conversation."
101
+ checked={effective.planStep}
102
+ onSave={(planStep) => update.mutateAsync({ ...saved, planStep })}
103
+ />
104
+ </SettingSection>
102
105
  </section>
103
106
 
104
107
  <SubagentModels
@@ -151,7 +154,7 @@ export function ModelsSection() {
151
154
  Dollars per million tokens, for Usage&apos;s estimates. Claude&apos;s
152
155
  are API list prices: the plan itself is measured by its window.
153
156
  </p>
154
- <div className="overflow-x-auto">
157
+ <ScrollTable>
155
158
  <table className="w-full text-xs">
156
159
  <thead className="text-muted-foreground text-left">
157
160
  <tr>
@@ -166,7 +169,9 @@ export function ModelsSection() {
166
169
  <tbody>
167
170
  {Object.entries(effective.prices).map(([ref, price]) => (
168
171
  <tr key={ref} className="border-border border-t">
169
- <td className="py-1 pr-2 font-mono">{ref}</td>
172
+ <td className="py-1 pr-2 font-mono whitespace-nowrap">
173
+ {ref}
174
+ </td>
170
175
  {PRICE_FIELDS.map((f) => (
171
176
  <td key={f.key} className="py-1 pr-2">
172
177
  <PriceInput
@@ -187,7 +192,7 @@ export function ModelsSection() {
187
192
  ))}
188
193
  </tbody>
189
194
  </table>
190
- </div>
195
+ </ScrollTable>
191
196
  </section>
192
197
  {update.isError && (
193
198
  <p className="text-destructive text-sm">{update.error.message}</p>
@@ -28,7 +28,7 @@ const KINDS: {
28
28
  }[] = [
29
29
  { value: "tokenvault", label: "tokenvault", a: "Project", b: "Description" },
30
30
  { value: "keychain", label: "macOS Keychain", a: "Service", b: "Account" },
31
- { value: "env", label: "Environment variable", a: "Variable (…_API_KEY)" },
31
+ { value: "env", label: "Environment variable", a: "Variable name" },
32
32
  ];
33
33
 
34
34
  function refFields(ref: SecretRef | undefined): [string, string] {
@@ -11,6 +11,7 @@ import {
11
11
  } from "@/components/ui/select";
12
12
  import { useModelChoices } from "@/data/models";
13
13
  import type { ModelChoice } from "@/lib/models/catalog";
14
+ import { TWO_LINE_TRIGGER, ValueLabel } from "@/components/Settings/ValueLabel";
14
15
  import { AvailabilityDot, LocalityBadge } from "./ModelBadges";
15
16
 
16
17
  const INHERIT = "__inherit";
@@ -41,6 +42,7 @@ export function RoleModelSelect({
41
42
  id,
42
43
  value,
43
44
  inheritLabel,
45
+ inheritDetail,
44
46
  claudeOnly,
45
47
  gateOnly,
46
48
  blockedBy,
@@ -50,6 +52,8 @@ export function RoleModelSelect({
50
52
  id?: string;
51
53
  value: string | null;
52
54
  inheritLabel: string;
55
+ // What the inherited choice resolves to (a model id), shown under it.
56
+ inheritDetail?: string;
53
57
  claudeOnly?: boolean;
54
58
  // Only models that can serve a merge gate are selectable.
55
59
  gateOnly?: boolean;
@@ -71,13 +75,21 @@ export function RoleModelSelect({
71
75
  onValueChange={(v) => onChange(v === INHERIT ? null : v)}
72
76
  disabled={disabled}
73
77
  >
74
- <SelectTrigger id={id} className="h-11 md:h-9">
78
+ <SelectTrigger id={id} className={TWO_LINE_TRIGGER}>
75
79
  <SelectValue>
76
- {current ? <Row choice={current} /> : (value ?? inheritLabel)}
80
+ {current ? (
81
+ <Row choice={current} />
82
+ ) : value ? (
83
+ <ValueLabel label={value} />
84
+ ) : (
85
+ <ValueLabel label={inheritLabel} detail={inheritDetail} />
86
+ )}
77
87
  </SelectValue>
78
88
  </SelectTrigger>
79
89
  <SelectContent>
80
- <SelectItem value={INHERIT}>{inheritLabel}</SelectItem>
90
+ <SelectItem value={INHERIT}>
91
+ <ValueLabel label={inheritLabel} detail={inheritDetail} />
92
+ </SelectItem>
81
93
  {value && !current && <SelectItem value={value}>{value}</SelectItem>}
82
94
  {groups.map((g) => (
83
95
  <SelectGroup key={g}>
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useState } from "react";
4
+ import { SettingRow, SettingSection } from "@/components/Settings/SettingRow";
4
5
  import { Button } from "@/components/ui/button";
5
6
  import { Input } from "@/components/ui/input";
6
7
  import {
@@ -46,58 +47,72 @@ export function SubagentModels({
46
47
  on Haiku, anything that plans, decides or reviews on Sonnet. A new
47
48
  session picks a change up.
48
49
  </p>
49
- <ul className="space-y-2">
50
+ <SettingSection>
50
51
  {Object.entries(rows).map(([type, tier]) => {
51
52
  const custom = !(type in DEFAULT_SUBAGENT_MODELS);
53
+ const changed = saved?.[type] !== undefined;
52
54
  return (
53
- <li
55
+ <SettingRow
54
56
  key={type}
55
- className="bg-muted/40 flex items-center gap-2 rounded-lg px-3 py-2"
56
- >
57
- <span className="min-w-0 flex-1 truncate font-mono text-sm">
58
- {type}
59
- </span>
60
- <Select
61
- value={tier}
62
- disabled={disabled}
63
- onValueChange={(v) => set(type, v as SubagentTier)}
64
- >
65
- <SelectTrigger
66
- aria-label={`Model for ${type}`}
67
- className="min-h-11 w-44"
68
- >
69
- <SelectValue />
70
- </SelectTrigger>
71
- <SelectContent>
72
- {SUBAGENT_TIERS.map((t) => (
73
- <SelectItem
74
- key={t}
75
- value={t}
76
- disabled={t === "haiku" && mustNotBeHaiku(type)}
57
+ id={`subagent-${type}`}
58
+ label={type}
59
+ resetLabel={
60
+ custom ? undefined : TIER_LABELS[DEFAULT_SUBAGENT_MODELS[type]]
61
+ }
62
+ onReset={
63
+ !custom && changed
64
+ ? () => {
65
+ const next = { ...saved };
66
+ delete next[type];
67
+ onChange(next);
68
+ }
69
+ : undefined
70
+ }
71
+ control={
72
+ <>
73
+ <Select
74
+ value={tier}
75
+ disabled={disabled}
76
+ onValueChange={(v) => set(type, v as SubagentTier)}
77
+ >
78
+ <SelectTrigger
79
+ aria-label={`Model for ${type}`}
80
+ className="min-h-11 w-40 md:min-h-9"
77
81
  >
78
- {TIER_LABELS[t]}
79
- </SelectItem>
80
- ))}
81
- </SelectContent>
82
- </Select>
83
- {(custom || saved?.[type] !== undefined) && (
84
- <Button
85
- variant="ghost"
86
- className="min-h-11"
87
- disabled={disabled}
88
- onClick={() => {
89
- const next = { ...saved };
90
- delete next[type];
91
- onChange(next);
92
- }}
93
- >
94
- {custom ? "Remove" : "Reset"}
95
- </Button>
96
- )}
97
- </li>
82
+ <SelectValue />
83
+ </SelectTrigger>
84
+ <SelectContent>
85
+ {SUBAGENT_TIERS.map((t) => (
86
+ <SelectItem
87
+ key={t}
88
+ value={t}
89
+ disabled={t === "haiku" && mustNotBeHaiku(type)}
90
+ >
91
+ {TIER_LABELS[t]}
92
+ </SelectItem>
93
+ ))}
94
+ </SelectContent>
95
+ </Select>
96
+ {custom && (
97
+ <Button
98
+ variant="ghost"
99
+ className="min-h-11 md:min-h-9"
100
+ disabled={disabled}
101
+ onClick={() => {
102
+ const next = { ...saved };
103
+ delete next[type];
104
+ onChange(next);
105
+ }}
106
+ >
107
+ Remove
108
+ </Button>
109
+ )}
110
+ </>
111
+ }
112
+ />
98
113
  );
99
114
  })}
100
- </ul>
115
+ </SettingSection>
101
116
  <div className="flex gap-2">
102
117
  <Input
103
118
  value={adding}
@@ -22,32 +22,25 @@ export function PhoneNotifyForm() {
22
22
  return (
23
23
  <div className="space-y-5">
24
24
  <p className="text-muted-foreground text-sm">
25
- Your phone gets a failed schedule run, and anything an agent sends with{" "}
26
- <code className="font-mono text-xs">aos notify</code>. At most one a
27
- minute from each; the rest wait and go together.
25
+ Your phone gets a failed schedule run and anything an agent sends you.
26
+ At most one a minute from each; the rest wait and go together.
28
27
  </p>
29
28
 
30
29
  <div className="bg-foreground/[0.03] rounded-lg px-3 py-2.5 text-sm">
31
30
  {settings.active === "command" ? (
32
31
  <>
33
- Sending through{" "}
34
- <code className="font-mono text-xs">AGENTOS_NOTIFY_CMD</code>, set
35
- on this machine. It takes priority over Telegram below.
32
+ Sending through a command set up on this machine. It takes priority
33
+ over Telegram below.
36
34
  </>
37
35
  ) : settings.active === "telegram" ? (
38
36
  <>Sending through the Telegram bot below.</>
39
37
  ) : (
40
- <>
41
- Not set up. Add a Telegram bot below, or set{" "}
42
- <code className="font-mono text-xs">AGENTOS_NOTIFY_CMD</code> to a
43
- command that reads the message on stdin.
44
- </>
38
+ <>Not set up. Add a Telegram bot below.</>
45
39
  )}
46
40
  </div>
47
41
 
48
42
  <div className="space-y-2">
49
- <p className="label-mono text-muted-foreground">Telegram</p>
50
- <SettingSection>
43
+ <SettingSection title="Telegram">
51
44
  <TextSetting
52
45
  id="tg-chat"
53
46
  label="Chat id"
@@ -3,6 +3,7 @@
3
3
  import { useState } from "react";
4
4
  import { useSnapshot } from "valtio";
5
5
  import { ArrowLeft, ChefHat, Plus } from "lucide-react";
6
+ import { ListSection } from "@/components/Settings/ListSection";
6
7
  import { Button } from "@/components/ui/button";
7
8
  import {
8
9
  useRecipeDraft,
@@ -30,7 +31,7 @@ function Row({ r, onOpen }: { r: RecipeView; onOpen: () => void }) {
30
31
  <button
31
32
  type="button"
32
33
  onClick={onOpen}
33
- className="border-border/60 hover:bg-foreground/[0.03] focus-visible:ring-ring flex min-h-14 w-full items-center gap-3 rounded-xl border px-3 py-2.5 text-left outline-none focus-visible:ring-2"
34
+ className="hover:bg-foreground/[0.03] focus-visible:ring-ring flex min-h-14 w-full items-center gap-3 px-3 py-2.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset"
34
35
  >
35
36
  <div className="min-w-0 flex-1 space-y-0.5">
36
37
  <div className="flex min-w-0 items-center gap-2">
@@ -40,11 +41,15 @@ function Row({ r, onOpen }: { r: RecipeView; onOpen: () => void }) {
40
41
  </span>
41
42
  </div>
42
43
  <p className="text-muted-foreground truncate text-xs">
43
- {r.timer
44
- ? `${r.timer.description}${r.timer.enabled && r.timer.nextRunAt ? ` · next ${formatRunTime(r.timer.nextRunAt, r.timer.timezone)}` : ""}`
45
- : "No timer"}
46
- {last && ` · ${formatRunTime(sqlMs(last.created_at))}`}
47
- {r.totals.costUsd > 0 && ` · ${money(r.totals.costUsd)} total`}
44
+ {[
45
+ r.timer
46
+ ? `${r.timer.description}${r.timer.enabled && r.timer.nextRunAt ? ` · next ${formatRunTime(r.timer.nextRunAt, r.timer.timezone)}` : ""}`
47
+ : null,
48
+ last ? formatRunTime(sqlMs(last.created_at)) : null,
49
+ r.totals.costUsd > 0 ? `${money(r.totals.costUsd)} total` : null,
50
+ ]
51
+ .filter(Boolean)
52
+ .join(" · ")}
48
53
  </p>
49
54
  </div>
50
55
  {last && <RunChip status={last.status} />}
@@ -185,9 +190,15 @@ export function RecipesPanel() {
185
190
  </p>
186
191
  </div>
187
192
  )}
188
- {recipes.map((r) => (
189
- <Row key={r.id} r={r} onOpen={() => toDetail(r.id)} />
190
- ))}
193
+ {recipes.length > 0 && (
194
+ <ListSection
195
+ title="Saved"
196
+ items={recipes}
197
+ keyOf={(r) => r.id}
198
+ render={(r) => <Row r={r} onOpen={() => toDetail(r.id)} />}
199
+ empty=""
200
+ />
201
+ )}
191
202
  </div>
192
203
  )}
193
204
  {view.mode === "detail" && (
@@ -52,7 +52,7 @@ export function RunHistory({ runs }: { runs: RecipeRun[] }) {
52
52
  </p>
53
53
  <p className="text-muted-foreground truncate text-xs">
54
54
  {[
55
- triggerLabel(r),
55
+ r.trigger === "manual" ? null : triggerLabel(r),
56
56
  r.pr_number ? `PR #${r.pr_number}` : null,
57
57
  stats,
58
58
  ]