@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.
- package/app/api/approvals/route.ts +5 -1
- package/app/api/health/config/route.ts +2 -0
- package/app/api/health/route.ts +4 -2
- package/app/globals.css +6 -0
- package/components/Audits/AuditsPanel.tsx +42 -35
- package/components/Chat/quote-selection.test.ts +6 -0
- package/components/Chat/quote-selection.ts +13 -10
- package/components/Devices/ConnectCard.tsx +52 -56
- package/components/Devices/DeviceGroupRow.tsx +65 -0
- package/components/Devices/DeviceRow.tsx +2 -2
- package/components/Devices/DevicesPanel.tsx +63 -49
- package/components/Devices/NetworkSection.tsx +29 -26
- package/components/Devices/TailscaleCard.tsx +9 -10
- package/components/Devices/group.test.ts +66 -0
- package/components/Devices/group.ts +67 -0
- package/components/Health/HealthPanel.tsx +5 -81
- package/components/Health/MachineChecks.tsx +20 -0
- package/components/Health/SweepRuns.tsx +46 -51
- package/components/Health/Thresholds.tsx +50 -0
- package/components/Merge/GlobalMergeSection.tsx +5 -6
- package/components/Merge/MergeApprovalToggle.tsx +20 -14
- package/components/Merge/MergeSettingsFields.tsx +0 -2
- package/components/Merge/WorktreeSweepSection.tsx +15 -34
- package/components/Models/AgentRoleCard.tsx +7 -7
- package/components/Models/ModelSelect.tsx +14 -3
- package/components/Models/ModelsSection.tsx +42 -37
- package/components/Models/ProviderCard.tsx +1 -1
- package/components/Models/RoleModelSelect.tsx +15 -3
- package/components/Models/SubagentModels.tsx +60 -45
- package/components/PhoneNotify/PhoneNotifyForm.tsx +6 -13
- package/components/Recipes/RecipesPanel.tsx +20 -9
- package/components/Recipes/RunHistory.tsx +1 -1
- package/components/Schedules/SchedulesPanel.tsx +12 -10
- 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 +2 -2
- package/components/Settings/{NoApprovalsSection.test.ts → ApprovalsSection.test.ts} +61 -22
- package/components/Settings/ApprovalsSection.tsx +93 -0
- package/components/Settings/ExperimentsSection.test.ts +20 -4
- package/components/Settings/ExperimentsSection.tsx +54 -34
- package/components/Settings/GeneralSection.tsx +39 -0
- package/components/Settings/ListSection.test.ts +85 -0
- package/components/Settings/ListSection.tsx +132 -0
- package/components/Settings/NotificationsSection.tsx +31 -29
- package/components/Settings/PickerSetting.tsx +5 -1
- package/components/Settings/SettingRow.tsx +91 -49
- package/components/Settings/SettingsDialog.test.ts +31 -4
- package/components/Settings/SettingsDialog.tsx +22 -17
- package/components/Settings/ValueLabel.tsx +31 -0
- package/components/Settings/settings-fields.test.ts +81 -2
- package/components/Settings/settings-fields.tsx +28 -27
- package/components/Shares/SharedLinksPanel.tsx +11 -14
- package/components/Updates/AutoUpdates.tsx +12 -8
- package/components/Updates/UpdatesSection.tsx +18 -17
- package/components/Workspaces/HqFolderForm.tsx +21 -19
- package/components/Workspaces/McpServersForm.tsx +20 -28
- package/components/Workspaces/ProjectTiersForm.tsx +25 -12
- package/components/Workspaces/RuleEditor.tsx +86 -0
- package/components/Workspaces/RulesForm.test.ts +58 -31
- package/components/Workspaces/RulesForm.tsx +129 -58
- package/components/Workspaces/WorkspaceModeForm.tsx +3 -3
- package/components/Workspaces/WorkspaceModelsForm.tsx +40 -41
- 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 +51 -79
- package/components/Workspaces/useSettingsWorkspace.ts +47 -0
- package/components/ui/switch.tsx +1 -1
- package/content/whats-new.json +40 -0
- package/data/approvals/index.ts +2 -2
- package/data/health/index.ts +3 -0
- package/hooks/useAutoSave.ts +18 -5
- package/hooks/useSettingsUrl.ts +6 -3
- package/lib/approvals/describe.ts +64 -0
- package/lib/away/deploy.ts +34 -7
- package/lib/health/config.ts +70 -17
- package/lib/models/plan-fresh.ts +61 -0
- package/lib/models/plan-step.ts +40 -24
- package/lib/models/providers.ts +1 -1
- package/lib/orchestrator/scope.ts +10 -0
- package/lib/recipes/settle.ts +22 -0
- package/lib/settings-url.ts +14 -2
- 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/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/Settings/NoApprovalsSection.tsx +0 -103
- package/components/Settings/SettingToggle.tsx +0 -36
- 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 = () => ({
|
|
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.
|
package/app/api/health/route.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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="
|
|
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
|
-
<
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
124
|
-
'input, textarea, select,
|
|
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
|
|
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(
|
|
130
|
+
return !!el?.closest(selector);
|
|
130
131
|
}
|
|
131
132
|
|
|
132
|
-
// Whether Enter quotes: the button is showing for a live selection,
|
|
133
|
-
//
|
|
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
|
-
!
|
|
144
|
-
!
|
|
145
|
-
!
|
|
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 {
|
|
4
|
-
import {
|
|
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) =>
|
|
27
|
+
onToggle: (on: boolean) => Promise<unknown>;
|
|
20
28
|
}) {
|
|
21
29
|
const { linked: enrolled, on } = connectView(connect);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
-
//
|
|
17
|
-
//
|
|
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-
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
</
|
|
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
|
-
|
|
31
|
-
<
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
}
|