@saadnvd1/agent-os 0.3.839 → 0.3.848

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 (100) hide show
  1. package/app/api/approvals/route.ts +5 -1
  2. package/app/api/health/config/route.ts +2 -0
  3. package/app/api/health/route.ts +4 -2
  4. package/app/globals.css +6 -0
  5. package/components/Audits/AuditsPanel.tsx +42 -35
  6. package/components/Chat/quote-selection.test.ts +6 -0
  7. package/components/Chat/quote-selection.ts +13 -10
  8. package/components/Devices/ConnectCard.tsx +52 -56
  9. package/components/Devices/DeviceGroupRow.tsx +65 -0
  10. package/components/Devices/DeviceRow.tsx +2 -2
  11. package/components/Devices/DevicesPanel.tsx +63 -49
  12. package/components/Devices/NetworkSection.tsx +29 -26
  13. package/components/Devices/TailscaleCard.tsx +9 -10
  14. package/components/Devices/group.test.ts +66 -0
  15. package/components/Devices/group.ts +67 -0
  16. package/components/Health/HealthPanel.tsx +5 -81
  17. package/components/Health/MachineChecks.tsx +20 -0
  18. package/components/Health/SweepRuns.tsx +46 -51
  19. package/components/Health/Thresholds.tsx +50 -0
  20. package/components/Merge/GlobalMergeSection.tsx +5 -6
  21. package/components/Merge/MergeApprovalToggle.tsx +20 -14
  22. package/components/Merge/MergeSettingsFields.tsx +0 -2
  23. package/components/Merge/WorktreeSweepSection.tsx +15 -34
  24. package/components/Models/AgentRoleCard.tsx +7 -7
  25. package/components/Models/ModelSelect.tsx +14 -3
  26. package/components/Models/ModelsSection.tsx +42 -37
  27. package/components/Models/ProviderCard.tsx +1 -1
  28. package/components/Models/RoleModelSelect.tsx +15 -3
  29. package/components/Models/SubagentModels.tsx +60 -45
  30. package/components/PhoneNotify/PhoneNotifyForm.tsx +6 -13
  31. package/components/Recipes/RecipesPanel.tsx +20 -9
  32. package/components/Recipes/RunHistory.tsx +1 -1
  33. package/components/Schedules/SchedulesPanel.tsx +12 -10
  34. package/components/SessionList/GroupShelves.tsx +8 -3
  35. package/components/SessionList/OrchestratorPinRow.tsx +2 -2
  36. package/components/SessionList/RowParts.tsx +39 -35
  37. package/components/SessionList/RowRename.test.ts +86 -17
  38. package/components/SessionList/RowShell.tsx +2 -5
  39. package/components/SessionList/SessionRow.tsx +0 -1
  40. package/components/Settings/AppWideLimits.tsx +2 -2
  41. package/components/Settings/{NoApprovalsSection.test.ts → ApprovalsSection.test.ts} +61 -22
  42. package/components/Settings/ApprovalsSection.tsx +93 -0
  43. package/components/Settings/ExperimentsSection.test.ts +20 -4
  44. package/components/Settings/ExperimentsSection.tsx +54 -34
  45. package/components/Settings/GeneralSection.tsx +39 -0
  46. package/components/Settings/ListSection.test.ts +85 -0
  47. package/components/Settings/ListSection.tsx +132 -0
  48. package/components/Settings/NotificationsSection.tsx +31 -29
  49. package/components/Settings/PickerSetting.tsx +5 -1
  50. package/components/Settings/SettingRow.tsx +91 -49
  51. package/components/Settings/SettingsDialog.test.ts +31 -4
  52. package/components/Settings/SettingsDialog.tsx +22 -17
  53. package/components/Settings/ValueLabel.tsx +31 -0
  54. package/components/Settings/settings-fields.test.ts +81 -2
  55. package/components/Settings/settings-fields.tsx +28 -27
  56. package/components/Shares/SharedLinksPanel.tsx +11 -14
  57. package/components/Updates/AutoUpdates.tsx +12 -8
  58. package/components/Updates/UpdatesSection.tsx +18 -17
  59. package/components/Workspaces/HqFolderForm.tsx +21 -19
  60. package/components/Workspaces/McpServersForm.tsx +20 -28
  61. package/components/Workspaces/ProjectTiersForm.tsx +25 -12
  62. package/components/Workspaces/RuleEditor.tsx +86 -0
  63. package/components/Workspaces/RulesForm.test.ts +58 -31
  64. package/components/Workspaces/RulesForm.tsx +129 -58
  65. package/components/Workspaces/WorkspaceModeForm.tsx +3 -3
  66. package/components/Workspaces/WorkspaceModelsForm.tsx +40 -41
  67. package/components/Workspaces/WorkspacePanel.tsx +73 -0
  68. package/components/Workspaces/WorkspacePicker.tsx +44 -0
  69. package/components/Workspaces/WorkspaceSettings.test.ts +153 -0
  70. package/components/Workspaces/WorkspaceSettings.tsx +51 -79
  71. package/components/Workspaces/useSettingsWorkspace.ts +47 -0
  72. package/components/ui/switch.tsx +1 -1
  73. package/content/whats-new.json +40 -0
  74. package/data/approvals/index.ts +2 -2
  75. package/data/health/index.ts +3 -0
  76. package/hooks/useAutoSave.ts +18 -5
  77. package/hooks/useSettingsUrl.ts +6 -3
  78. package/lib/approvals/describe.ts +64 -0
  79. package/lib/away/deploy.ts +34 -7
  80. package/lib/health/config.ts +70 -17
  81. package/lib/models/plan-fresh.ts +61 -0
  82. package/lib/models/plan-step.ts +40 -24
  83. package/lib/models/providers.ts +1 -1
  84. package/lib/orchestrator/scope.ts +10 -0
  85. package/lib/recipes/settle.ts +22 -0
  86. package/lib/settings-url.ts +14 -2
  87. package/lib/updates/banner.ts +2 -0
  88. package/lib/updates/run.ts +9 -0
  89. package/lib/updates/view.ts +28 -6
  90. package/lib/worktree-deps/paths.ts +9 -4
  91. package/lib/worktrees.ts +3 -0
  92. package/package.json +1 -1
  93. package/scripts/autodeploy +11 -0
  94. package/scripts/autodeploy.test.ts +30 -0
  95. package/scripts/lib/update-verify.mjs +11 -0
  96. package/scripts/update-run.mjs +29 -0
  97. package/scripts/update-run.test.ts +62 -4
  98. package/components/Settings/NoApprovalsSection.tsx +0 -103
  99. package/components/Settings/SettingToggle.tsx +0 -36
  100. package/components/Settings/SetupSection.tsx +0 -46
@@ -1,6 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
3
3
  import { switchNoApprovals } from "@/lib/approvals/enable";
4
+ import { describeApproval } from "@/lib/approvals/describe";
4
5
  import { listAutoApprovals, noApprovalsOn } from "@/lib/approvals/no-approvals";
5
6
  import { NO_APPROVALS_ON_PRESENCE } from "@/lib/orchestrator/presence-binding";
6
7
  import { requireApprover } from "@/lib/security/approver";
@@ -8,7 +9,10 @@ import { relyingParty, verifyPresence } from "@/lib/security/presence";
8
9
  import { refusalResponse } from "@/lib/security/presence-http";
9
10
  import { requireLocalTrust } from "@/lib/security/route-guard";
10
11
 
11
- const view = () => ({ on: noApprovalsOn(), audit: listAutoApprovals(20) });
12
+ const view = () => ({
13
+ on: noApprovalsOn(),
14
+ audit: listAutoApprovals(100).map(describeApproval),
15
+ });
12
16
 
13
17
  // GET /api/approvals - whether No approvals mode is on, and its latest audit lines
14
18
  export function GET() {
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
2
2
  import {
3
3
  HEALTH_DEFAULTS,
4
4
  HEALTH_LABELS,
5
+ HEALTH_META,
5
6
  healthConfig,
6
7
  setHealthConfig,
7
8
  } from "@/lib/health/config";
@@ -10,6 +11,7 @@ const body = () => ({
10
11
  config: healthConfig(),
11
12
  defaults: HEALTH_DEFAULTS,
12
13
  labels: HEALTH_LABELS,
14
+ meta: HEALTH_META,
13
15
  });
14
16
 
15
17
  // GET /api/health/config - the thresholds, their defaults and labels.
@@ -1,9 +1,11 @@
1
1
  import { NextResponse } from "next/server";
2
+ import { buildId } from "@/lib/build";
2
3
 
3
4
  const startedAt = new Date(Date.now() - process.uptime() * 1000).toISOString();
4
5
 
5
6
  // GET /api/health - the server is up and answering; when it started, so a
6
- // deploy check can tell it restarted after a merge (lib/away/deploy.ts).
7
+ // deploy check can tell it restarted after a merge, and the build it runs, so
8
+ // a merge is only "deployed" when that build contains it (lib/away/deploy.ts).
7
9
  export function GET() {
8
- return NextResponse.json({ ok: true, startedAt });
10
+ return NextResponse.json({ ok: true, startedAt, build: buildId() });
9
11
  }
package/app/globals.css CHANGED
@@ -348,3 +348,9 @@ body {
348
348
  border-color: transparent;
349
349
  color: hsl(var(--muted-foreground));
350
350
  }
351
+
352
+ /* Toasts never sit over the Settings dialog: a rejected save shows on its
353
+ row (useAutoSave), and the update toast comes back when Settings closes. */
354
+ html[data-settings-open] [data-sonner-toaster] {
355
+ display: none;
356
+ }
@@ -3,6 +3,7 @@
3
3
  import { useEffect, useRef, useState } from "react";
4
4
  import { ArrowLeft, ChevronRight } from "lucide-react";
5
5
  import { Button } from "@/components/ui/button";
6
+ import { ListSection } from "@/components/Settings/ListSection";
6
7
  import { Markdown } from "@/components/Chat/Markdown";
7
8
  import {
8
9
  useAuditReportQuery,
@@ -26,12 +27,16 @@ const day = (iso: string) =>
26
27
  minute: "2-digit",
27
28
  });
28
29
 
29
- function stats(a: AuditListItem): string {
30
- if (a.kind !== "sessions")
31
- return a.findings === undefined
32
- ? a.title
33
- : `${a.title} · ${a.findings} findings`;
34
- return `${a.sessions ?? 0} sessions · ${tokens(a.tokensRead ?? 0)} tokens · ${a.findings ?? 0} findings`;
30
+ // The line under the title: when, then what it found.
31
+ function detail(a: AuditListItem): string {
32
+ const parts = [day(a.at)];
33
+ if (a.kind === "sessions")
34
+ parts.push(
35
+ `${a.sessions ?? 0} sessions`,
36
+ `${tokens(a.tokensRead ?? 0)} tokens`
37
+ );
38
+ if (a.findings !== undefined) parts.push(`${a.findings} findings`);
39
+ return parts.join(" · ");
35
40
  }
36
41
 
37
42
  function AuditRow({
@@ -46,24 +51,15 @@ function AuditRow({
46
51
  type="button"
47
52
  onClick={onOpen}
48
53
  data-audit={`${audit.kind}/${audit.id}`}
49
- 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"
54
+ 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"
50
55
  >
51
56
  <div className="min-w-0 flex-1 space-y-0.5">
52
- <div className="flex min-w-0 flex-col gap-0.5 md:flex-row md:items-center md:gap-2">
53
- <span className="flex shrink-0 items-center gap-2 text-sm font-medium">
54
- {day(audit.at)}
55
- {audit.kind !== "sessions" && (
56
- <span className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[11px] font-normal">
57
- {audit.kind}
58
- </span>
59
- )}
60
- </span>
61
- <span className="text-muted-foreground truncate text-[11px]">
62
- {stats(audit)}
63
- </span>
64
- </div>
57
+ <p className="line-clamp-2 text-sm font-medium break-words">
58
+ {audit.title}
59
+ </p>
60
+ <p className="text-muted-foreground text-xs">{detail(audit)}</p>
65
61
  {audit.headline && (
66
- <p className="text-muted-foreground truncate text-xs">
62
+ <p className="text-muted-foreground line-clamp-2 text-xs break-words">
67
63
  {audit.headline}
68
64
  </p>
69
65
  )}
@@ -140,22 +136,33 @@ export function AuditsPanel() {
140
136
  return <div className="bg-muted/40 h-32 animate-pulse rounded-xl" />;
141
137
  if (audits.error)
142
138
  return <p className="text-destructive text-sm">{audits.error.message}</p>;
143
- if (!audits.data?.length)
144
- return (
145
- <p className="text-muted-foreground text-sm">
146
- No audits yet. Run <code>/session-audit</code> in a session on this
147
- project to audit what your sessions spent and repeated.
148
- </p>
149
- );
139
+ const all = audits.data ?? [];
140
+ // A "While you were away" digest is a different thing from an audit.
141
+ const away = all.filter((a) => a.kind === "away");
142
+ const real = all.filter((a) => a.kind !== "away");
143
+ const key = (a: AuditListItem) => `${a.kind}/${a.id}`;
144
+ const row = (a: AuditListItem) => (
145
+ <AuditRow audit={a} onOpen={() => setOpen({ kind: a.kind, id: a.id })} />
146
+ );
150
147
  return (
151
- <div ref={listRef} className="space-y-2">
152
- {audits.data.map((a) => (
153
- <AuditRow
154
- key={`${a.kind}/${a.id}`}
155
- audit={a}
156
- onOpen={() => setOpen({ kind: a.kind, id: a.id })}
148
+ <div ref={listRef} className="space-y-5">
149
+ <ListSection
150
+ title="Session audits"
151
+ help="Where sessions spent tokens and repeated work."
152
+ items={real}
153
+ keyOf={key}
154
+ render={row}
155
+ empty="No audits yet. Run /session-audit in a session on this project to audit what your sessions spent and repeated."
156
+ />
157
+ {away.length > 0 && (
158
+ <ListSection
159
+ title="While you were away"
160
+ items={away}
161
+ keyOf={key}
162
+ render={row}
163
+ empty=""
157
164
  />
158
- ))}
165
+ )}
159
166
  </div>
160
167
  );
161
168
  }
@@ -244,6 +244,12 @@ describe("Enter quotes", () => {
244
244
  expect(press(true, key(button)).handled).toBe(false);
245
245
  });
246
246
 
247
+ it("fires when the selection ends on the button row below a reply", () => {
248
+ const button = document.querySelector("#a2 button")!;
249
+ select([reply(), 4], [button, 0]);
250
+ expect(press(true, key(root)).handled).toBe(true);
251
+ });
252
+
247
253
  it.each(["shiftKey", "metaKey", "ctrlKey", "altKey"])(
248
254
  "ignores Enter with %s",
249
255
  (mod) => {
@@ -120,18 +120,21 @@ export function placeQuote(
120
120
  };
121
121
  }
122
122
 
123
- const TYPES_OR_ACTS =
124
- 'input, textarea, select, button, a[href], summary, [contenteditable]:not([contenteditable="false"])';
123
+ const TYPES =
124
+ 'input, textarea, select, [contenteditable]:not([contenteditable="false"])';
125
+ const TYPES_OR_ACTS = `${TYPES}, button, a[href], summary`;
125
126
 
126
- function inTypingOrAction(node: Node | null): boolean {
127
+ function inside(node: Node | null, selector: string): boolean {
127
128
  const el =
128
129
  node && (node.nodeType === 1 ? (node as Element) : node.parentElement);
129
- return !!el?.closest(TYPES_OR_ACTS);
130
+ return !!el?.closest(selector);
130
131
  }
131
132
 
132
- // Whether Enter quotes: the button is showing for a live selection, and
133
- // neither the key's target nor the selection sits in the composer or any
134
- // other field, where Enter keeps its own meaning.
133
+ // Whether Enter quotes: the button is showing for a live selection, the key
134
+ // isn't aimed at a field or a focused button, and the selection doesn't sit
135
+ // in the composer or any other field, where Enter keeps its own meaning. A
136
+ // selection may end on a button: triple-clicking a reply's last line ends it
137
+ // at the start of the copy/quote row below.
135
138
  export function enterQuotes(
136
139
  visible: boolean,
137
140
  target: EventTarget | null,
@@ -140,9 +143,9 @@ export function enterQuotes(
140
143
  if (!visible || !sel || sel.isCollapsed || !sel.rangeCount) return false;
141
144
  if (!sel.toString().trim()) return false;
142
145
  return (
143
- !inTypingOrAction(target as Node | null) &&
144
- !inTypingOrAction(sel.anchorNode) &&
145
- !inTypingOrAction(sel.focusNode)
146
+ !inside(target as Node | null, TYPES_OR_ACTS) &&
147
+ !inside(sel.anchorNode, TYPES) &&
148
+ !inside(sel.focusNode, TYPES)
146
149
  );
147
150
  }
148
151
 
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { Cloud } from "lucide-react";
4
- import { Switch } from "@/components/ui/switch";
3
+ import { SettingRow } from "@/components/Settings/SettingRow";
4
+ import { SwitchSetting } from "@/components/Settings/settings-fields";
5
5
  import type { ConnectStatus } from "@/lib/connect/serve";
6
6
  import {
7
7
  CONNECT_SIGN_UP,
@@ -9,6 +9,14 @@ import {
9
9
  connectView,
10
10
  } from "@/lib/connect/view";
11
11
 
12
+ // An address is one token: keep it whole and cut it with an ellipsis rather
13
+ // than through a letter.
14
+ export const Address = ({ children }: { children: string }) => (
15
+ <span className="text-foreground inline-block max-w-full truncate align-bottom font-mono">
16
+ {children}
17
+ </span>
18
+ );
19
+
12
20
  export function ConnectCard({
13
21
  connect,
14
22
  canManage,
@@ -16,62 +24,50 @@ export function ConnectCard({
16
24
  }: {
17
25
  connect: ConnectStatus;
18
26
  canManage: boolean;
19
- onToggle: (on: boolean) => void;
27
+ onToggle: (on: boolean) => Promise<unknown>;
20
28
  }) {
21
29
  const { linked: enrolled, on } = connectView(connect);
22
- return (
23
- <div className="bg-muted/40 flex items-start gap-3 rounded-lg px-3 py-2.5">
24
- <Cloud className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
25
- <div className="min-w-0 flex-1">
26
- <p className="text-sm font-medium">
27
- Connect{" "}
28
- <span className="text-muted-foreground font-normal">
29
- · {STATE[connect.state]}
30
- </span>
31
- </p>
32
- <p className="text-muted-foreground text-xs break-all">
33
- {connect.hostname ? (
34
- <>
35
- Reachable from anywhere at{" "}
36
- <span className="text-foreground font-mono">
37
- https://{connect.hostname}
38
- </span>
39
- . Cloudflare carries the connection; every request still needs a
40
- paired device.
41
- </>
42
- ) : enrolled ? (
43
- <>Linked. The address appears once the tunnel starts.</>
44
- ) : (
45
- <>
46
- Not set up on this machine. Run{" "}
47
- <code className="text-foreground font-mono">
48
- agent-os connect
49
- </code>{" "}
50
- in a terminal.{" "}
51
- <a
52
- href={CONNECT_SIGN_UP}
53
- target="_blank"
54
- rel="noopener noreferrer"
55
- className="text-foreground flex min-h-11 w-fit items-center underline underline-offset-2 sm:min-h-6"
56
- >
57
- Set up Connect (free)
58
- </a>
59
- </>
60
- )}
61
- {connect.reason && connect.state !== "off"
62
- ? ` ${connect.reason.replace(/\.$/, "")}.`
63
- : ""}
64
- </p>
65
- </div>
66
- {enrolled && (
67
- <Switch
68
- checked={on}
69
- disabled={!canManage || connect.state === "refused"}
70
- onCheckedChange={onToggle}
71
- className="mt-0.5"
72
- aria-label="Connect"
73
- />
30
+ const label = `Connect · ${STATE[connect.state]}`;
31
+ const help = (
32
+ <>
33
+ {connect.hostname ? (
34
+ <>
35
+ Reachable from anywhere at{" "}
36
+ <Address>{`https://${connect.hostname}`}</Address>. Cloudflare carries
37
+ the connection; every request still needs a paired device.
38
+ </>
39
+ ) : enrolled ? (
40
+ <>Linked. The address appears once the tunnel starts.</>
41
+ ) : (
42
+ <>
43
+ Not set up on this machine. Run{" "}
44
+ <code className="text-foreground font-mono">agent-os connect</code> in
45
+ a terminal.{" "}
46
+ <a
47
+ href={CONNECT_SIGN_UP}
48
+ target="_blank"
49
+ rel="noopener noreferrer"
50
+ className="text-foreground underline underline-offset-2"
51
+ >
52
+ Set up Connect (free)
53
+ </a>
54
+ </>
74
55
  )}
75
- </div>
56
+ {connect.reason && connect.state !== "off"
57
+ ? ` ${connect.reason.replace(/\.$/, "")}.`
58
+ : ""}
59
+ </>
60
+ );
61
+ if (!enrolled)
62
+ return <SettingRow id="connect" label={label} help={help} control={null} />;
63
+ return (
64
+ <SwitchSetting
65
+ id="connect"
66
+ label={label}
67
+ help={help}
68
+ checked={on}
69
+ locked={!canManage || connect.state === "refused"}
70
+ onSave={onToggle}
71
+ />
76
72
  );
77
73
  }
@@ -0,0 +1,65 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Trash2 } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { InlineConfirm } from "@/components/Settings/InlineConfirm";
7
+ import { useRevokeDevice } from "@/data/devices";
8
+ import type { ListedDevice } from "@/lib/security/devices";
9
+ import { DeviceIcon, lastSeen } from "./DeviceRow";
10
+
11
+ // Identical devices as one row, with one button to remove the lot.
12
+ export function DeviceGroupRow({
13
+ devices,
14
+ canManage,
15
+ }: {
16
+ devices: ListedDevice[];
17
+ canManage: boolean;
18
+ }) {
19
+ const revoke = useRevokeDevice();
20
+ const [asking, setAsking] = useState(false);
21
+ const first = devices[0];
22
+ return (
23
+ <div className="px-3 py-2.5">
24
+ <div className="flex items-center gap-3">
25
+ <DeviceIcon ua={first.user_agent} />
26
+ <div className="min-w-0 flex-1">
27
+ <p className="truncate text-sm font-medium">
28
+ {first.name}
29
+ <span className="text-muted-foreground ml-1.5 font-normal tabular-nums">
30
+ × {devices.length}
31
+ </span>
32
+ </p>
33
+ <p className="text-muted-foreground truncate text-xs">
34
+ Last used {lastSeen(first.last_seen_at)}
35
+ </p>
36
+ </div>
37
+ {canManage && (
38
+ <Button
39
+ variant="ghost"
40
+ size="sm"
41
+ className="h-11 shrink-0 sm:h-8"
42
+ onClick={() => setAsking(true)}
43
+ disabled={revoke.isPending}
44
+ aria-label={`Remove all ${devices.length} ${first.name} devices`}
45
+ >
46
+ <Trash2 className="h-4 w-4" />
47
+ Remove all
48
+ </Button>
49
+ )}
50
+ </div>
51
+ {asking && (
52
+ <InlineConfirm
53
+ label={`Remove ${devices.length} ${first.name} devices`}
54
+ message={`Remove all ${devices.length} devices called "${first.name}"? Each one has to be paired again to come back.`}
55
+ confirmLabel={`Remove ${devices.length}`}
56
+ onConfirm={() => {
57
+ setAsking(false);
58
+ for (const d of devices) revoke.mutate(d.id);
59
+ }}
60
+ onCancel={() => setAsking(false)}
61
+ />
62
+ )}
63
+ </div>
64
+ );
65
+ }
@@ -25,7 +25,7 @@ export function lastSeen(s: string | null, now = Date.now()): string {
25
25
  return `${Math.round(mins / 1440)}d ago`;
26
26
  }
27
27
 
28
- function DeviceIcon({ ua }: { ua: string | null }) {
28
+ export function DeviceIcon({ ua }: { ua: string | null }) {
29
29
  const className = "text-muted-foreground h-4 w-4 shrink-0";
30
30
  if (/iPad|Tablet/i.test(ua ?? "")) return <Tablet className={className} />;
31
31
  if (/iPhone|Android|Mobile/i.test(ua ?? ""))
@@ -63,7 +63,7 @@ export function DeviceRow({
63
63
  };
64
64
 
65
65
  return (
66
- <div className="bg-muted/40 rounded-lg px-3 py-2.5">
66
+ <div className="px-3 py-2.5">
67
67
  <div className="flex items-center gap-3">
68
68
  <DeviceIcon ua={device.user_agent} />
69
69
  <div className="min-w-0 flex-1">
@@ -1,11 +1,14 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
3
+ import { useMemo, useState } from "react";
4
4
  import { Plus } from "lucide-react";
5
5
  import { Button } from "@/components/ui/button";
6
+ import { ListSection } from "@/components/Settings/ListSection";
6
7
  import { useDevicesQuery } from "@/data/devices";
7
8
  import { AddDevicePanel } from "./AddDevicePanel";
9
+ import { DeviceGroupRow } from "./DeviceGroupRow";
8
10
  import { DeviceRow } from "./DeviceRow";
11
+ import { groupDevices, type DeviceEntry } from "./group";
9
12
  import { NetworkSection } from "./NetworkSection";
10
13
  import { PasskeysSection } from "./PasskeysSection";
11
14
 
@@ -13,36 +16,40 @@ import { PasskeysSection } from "./PasskeysSection";
13
16
  export const canManageDevices = (via: string | null | undefined) =>
14
17
  via === "loopback" || via === "tailnet";
15
18
 
16
- // Phones, tablets and other computers that can use this AgentOS, its
17
- // passkeys, and how they reach it. Settings > Devices & access.
19
+ // How devices reach this machine, its passkeys, then the devices paired to
20
+ // it. Settings > Devices & access.
18
21
  export function DevicesPanel() {
19
22
  const { data, isPending, isError, error } = useDevicesQuery(true);
20
23
  const [adding, setAdding] = useState(false);
21
24
  const canManage = canManageDevices(data?.current.via);
25
+ const { recent, older } = useMemo(
26
+ () => groupDevices(data?.devices ?? [], data?.current.deviceId),
27
+ [data]
28
+ );
29
+ const render = (e: DeviceEntry) =>
30
+ e.kind === "group" ? (
31
+ <DeviceGroupRow devices={e.devices} canManage={canManage} />
32
+ ) : (
33
+ <DeviceRow
34
+ device={e.device}
35
+ isCurrent={e.device.id === data?.current.deviceId}
36
+ canManage={canManage}
37
+ />
38
+ );
22
39
 
23
40
  return (
24
- <div className="space-y-4">
25
- <p className="text-muted-foreground text-sm">
26
- Phones, tablets and other computers that can use this AgentOS. Each one
27
- is paired once and can be removed at any time.
28
- </p>
41
+ <div className="space-y-5">
42
+ <section className="space-y-2">
43
+ <h3 className="text-sm font-semibold">Access</h3>
44
+ <NetworkSection open canManage={canManage} />
45
+ </section>
29
46
 
30
- {adding ? (
31
- <AddDevicePanel onDone={() => setAdding(false)} />
32
- ) : canManage ? (
33
- <Button onClick={() => setAdding(true)} className="h-11 w-full sm:h-9">
34
- <Plus className="h-4 w-4" />
35
- Add a device
36
- </Button>
37
- ) : (
38
- data && (
39
- <p className="text-muted-foreground text-sm">
40
- Add devices and change access on the machine running AgentOS, or
41
- over Tailscale.
42
- </p>
43
- )
44
- )}
47
+ <section className="space-y-2">
48
+ <h3 className="text-sm font-semibold">Passkeys</h3>
49
+ <PasskeysSection open />
50
+ </section>
45
51
 
52
+ {adding && <AddDevicePanel onDone={() => setAdding(false)} />}
46
53
  {data && !data.push.linked && (
47
54
  <p className="text-muted-foreground text-sm">
48
55
  Phone push needs this machine linked to Connect. Until then,
@@ -55,32 +62,39 @@ export function DevicesPanel() {
55
62
  meanwhile.
56
63
  </p>
57
64
  )}
58
-
59
- <div className="space-y-2">
60
- {isPending && (
61
- <div className="bg-muted/40 h-14 animate-pulse rounded-lg" />
62
- )}
63
- {isError && <p className="text-destructive text-sm">{error.message}</p>}
64
- {data?.devices.length === 0 && (
65
- <p className="text-muted-foreground text-sm">
66
- No devices paired yet.
67
- </p>
68
- )}
69
- {data?.devices.map((d) => (
70
- <DeviceRow
71
- key={d.id}
72
- device={d}
73
- isCurrent={d.id === data.current.deviceId}
74
- canManage={canManage}
75
- />
76
- ))}
77
- </div>
78
-
79
- <p className="pt-2 text-sm font-medium">Passkeys</p>
80
- <PasskeysSection open />
81
-
82
- <p className="pt-2 text-sm font-medium">Access</p>
83
- <NetworkSection open canManage={canManage} />
65
+ {isPending && (
66
+ <div className="bg-muted/40 h-14 animate-pulse rounded-lg" />
67
+ )}
68
+ {isError && <p className="text-destructive text-sm">{error.message}</p>}
69
+ {data && (
70
+ <ListSection
71
+ title="Devices"
72
+ help={
73
+ canManage
74
+ ? "Phones, tablets and other computers paired to this AgentOS. Each is paired once and can be removed at any time."
75
+ : "Add devices and change access on the machine running AgentOS, or over Tailscale."
76
+ }
77
+ items={recent}
78
+ older={older}
79
+ olderLabel="Not used for a month"
80
+ keyOf={(e) => e.key}
81
+ render={render}
82
+ empty="No devices paired yet."
83
+ action={
84
+ canManage &&
85
+ !adding && (
86
+ <Button
87
+ onClick={() => setAdding(true)}
88
+ size="sm"
89
+ className="h-11 sm:h-8"
90
+ >
91
+ <Plus className="h-4 w-4" />
92
+ Add a device
93
+ </Button>
94
+ )
95
+ }
96
+ />
97
+ )}
84
98
  </div>
85
99
  );
86
100
  }