@saadnvd1/agent-os 0.3.532 → 0.3.534
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/README.md +71 -3
- package/app/api/devices/connect/route.ts +79 -0
- package/app/api/features/pieces/[id]/release/route.ts +16 -0
- package/app/api/features/route.ts +8 -0
- package/app/api/recipes/[id]/carry/clear/route.ts +20 -0
- package/app/api/recipes/[id]/preview/route.ts +19 -0
- package/app/api/recipes/[id]/route.ts +51 -0
- package/app/api/recipes/[id]/run/route.ts +35 -0
- package/app/api/recipes/[id]/runs/route.ts +23 -0
- package/app/api/recipes/draft/route.ts +14 -0
- package/app/api/recipes/route.ts +35 -0
- package/app/api/tasks/[id]/plan-edits/route.ts +34 -0
- package/components/CommandPalette/useAppCommands.ts +20 -0
- package/components/Devices/ConnectCard.tsx +2 -15
- package/components/Features/FeaturesSection.tsx +100 -0
- package/components/Features/index.ts +1 -0
- package/components/Recipes/RecipeForm.tsx +251 -0
- package/components/Recipes/RecipePage.tsx +127 -0
- package/components/Recipes/RecipesPanel.tsx +225 -0
- package/components/Recipes/RunChip.tsx +16 -0
- package/components/Recipes/RunHistory.tsx +88 -0
- package/components/Recipes/RunSheet.tsx +84 -0
- package/components/Recipes/TimerFields.tsx +193 -0
- package/components/Recipes/format.ts +51 -0
- package/components/Recipes/index.ts +1 -0
- package/components/Schedules/SchedulesPanel.tsx +1 -0
- package/components/SessionList/RowSubtitle.tsx +16 -0
- package/components/SessionList/SessionRowMenu.tsx +29 -0
- package/components/SessionList/SidebarHeader.tsx +5 -0
- package/components/SessionList/WorkspaceSwitcher.tsx +20 -4
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Tasks/PlanReview.tsx +253 -0
- package/components/Tasks/TaskRow.tsx +19 -44
- package/components/Tasks/TasksDialog.tsx +2 -0
- package/components/Welcome/Block.tsx +26 -0
- package/components/Welcome/ConnectBlock.tsx +169 -0
- package/components/Welcome/DevicesStep.tsx +4 -26
- package/data/devices/queries.ts +21 -1
- package/data/features/index.ts +40 -0
- package/data/push/topics.ts +8 -0
- package/data/recipes/index.ts +129 -0
- package/data/tasks/index.ts +14 -0
- package/lib/away/run.ts +4 -1
- package/lib/connect/cli.ts +6 -18
- package/lib/connect/link.ts +35 -0
- package/lib/connect/serve.ts +12 -1
- package/lib/connect/view.ts +28 -0
- package/lib/db/changes.ts +8 -0
- package/lib/db/migrations.ts +127 -0
- package/lib/db/queries.ts +4 -1
- package/lib/db/types.ts +6 -0
- package/lib/models/plan-step.ts +67 -6
- package/lib/notify/copy.ts +28 -10
- package/lib/notify/push/events.ts +36 -2
- package/lib/notify/push/policy.ts +2 -0
- package/lib/notify/push-types.ts +7 -3
- package/lib/orchestrator/brief.ts +13 -0
- package/lib/orchestrator/conditions.ts +3 -1
- package/lib/orchestrator/deliver.ts +2 -0
- package/lib/orchestrator/digest.ts +27 -1
- package/lib/orchestrator/plan-pieces.ts +41 -0
- package/lib/orchestrator/recipe-tools.ts +159 -0
- package/lib/orchestrator/review.ts +2 -1
- package/lib/orchestrator/serve.ts +25 -0
- package/lib/orchestrator/tool-names.ts +5 -0
- package/lib/orchestrator/tool-schemas.ts +54 -0
- package/lib/orchestrator/tools.ts +10 -0
- package/lib/plans/features.ts +200 -0
- package/lib/plans/pieces.ts +366 -0
- package/lib/plans/release.ts +323 -0
- package/lib/plans/view.ts +85 -0
- package/lib/recipes/api.ts +19 -0
- package/lib/recipes/chip.ts +40 -0
- package/lib/recipes/compose.ts +78 -0
- package/lib/recipes/draft.ts +93 -0
- package/lib/recipes/index.ts +54 -0
- package/lib/recipes/result.ts +59 -0
- package/lib/recipes/run.ts +202 -0
- package/lib/recipes/settle.ts +465 -0
- package/lib/recipes/store.ts +378 -0
- package/lib/recipes/test-fixtures.ts +49 -0
- package/lib/recipes/timer.ts +107 -0
- package/lib/recipes/types.ts +116 -0
- package/lib/schedules/index.ts +4 -1
- package/lib/schedules/start.ts +33 -9
- package/lib/schedules/store.ts +6 -2
- package/lib/security/demo.ts +2 -0
- package/lib/settings-url.ts +1 -0
- package/lib/tasks/loop-types.ts +23 -0
- package/lib/tasks/plan-view.ts +38 -1
- package/package.json +1 -1
- package/server.ts +2 -0
- package/stores/recipesUi.ts +53 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
6
|
+
import { Markdown } from "@/components/Chat/Markdown";
|
|
7
|
+
import { useApprovePlan, useDropTask, usePlanEdits } from "@/data/tasks";
|
|
8
|
+
import type { PlanPieceView } from "@/lib/tasks/loop-types";
|
|
9
|
+
import type { TaskView } from "@/lib/tasks";
|
|
10
|
+
import { cn } from "@/lib/utils";
|
|
11
|
+
|
|
12
|
+
const CIRCLED = ["①", "②", "③", "④", "⑤", "⑥"];
|
|
13
|
+
const REASON: Record<string, string> = {
|
|
14
|
+
a: "spans repositories",
|
|
15
|
+
b: "must be live before the other can merge",
|
|
16
|
+
c: "needs you mid-way",
|
|
17
|
+
d: "must be revertible on its own",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function Piece({
|
|
21
|
+
p,
|
|
22
|
+
edits,
|
|
23
|
+
busy,
|
|
24
|
+
onEdit,
|
|
25
|
+
}: {
|
|
26
|
+
p: PlanPieceView;
|
|
27
|
+
edits: { merge: number[]; drop: number[] };
|
|
28
|
+
busy: boolean;
|
|
29
|
+
onEdit: (merge: number[], drop: number[]) => void;
|
|
30
|
+
}) {
|
|
31
|
+
const mark = CIRCLED[p.position - 1];
|
|
32
|
+
const off = p.edit !== null;
|
|
33
|
+
const without = (list: number[]) => list.filter((n) => n !== p.position);
|
|
34
|
+
return (
|
|
35
|
+
<li className={cn("space-y-1 px-3 py-2.5", off && "opacity-60")}>
|
|
36
|
+
<div className="flex items-baseline gap-2">
|
|
37
|
+
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
|
38
|
+
{mark} {p.name}
|
|
39
|
+
</span>
|
|
40
|
+
<span className="text-muted-foreground shrink-0 text-xs">
|
|
41
|
+
{p.project}
|
|
42
|
+
</span>
|
|
43
|
+
</div>
|
|
44
|
+
<p className="text-muted-foreground text-xs">{p.slice.split("\n")[0]}</p>
|
|
45
|
+
{p.afterPosition && (
|
|
46
|
+
<p className="text-xs">
|
|
47
|
+
after {CIRCLED[p.afterPosition - 1]} is {p.afterKind}
|
|
48
|
+
</p>
|
|
49
|
+
)}
|
|
50
|
+
{p.reasons.length > 0 && (
|
|
51
|
+
<p className="text-xs">
|
|
52
|
+
{p.reasons.map((r) => `(${r}) ${REASON[r]}`).join("; ")}
|
|
53
|
+
</p>
|
|
54
|
+
)}
|
|
55
|
+
{p.owns.length > 0 && (
|
|
56
|
+
<p className="text-muted-foreground text-xs">
|
|
57
|
+
owns: {p.owns.join(", ")}
|
|
58
|
+
</p>
|
|
59
|
+
)}
|
|
60
|
+
{p.position > 1 && (
|
|
61
|
+
<div className="flex flex-wrap items-center gap-2 pt-1">
|
|
62
|
+
{off ? (
|
|
63
|
+
<>
|
|
64
|
+
<span className="bg-foreground/10 rounded-full px-2 py-0.5 text-[11px]">
|
|
65
|
+
{p.edit === "merge"
|
|
66
|
+
? `Merged into ${CIRCLED[p.position - 2]}`
|
|
67
|
+
: "Dropped"}
|
|
68
|
+
</span>
|
|
69
|
+
<Button
|
|
70
|
+
size="sm"
|
|
71
|
+
variant="ghost"
|
|
72
|
+
className="h-11 md:h-8"
|
|
73
|
+
disabled={busy}
|
|
74
|
+
onClick={() =>
|
|
75
|
+
onEdit(without(edits.merge), without(edits.drop))
|
|
76
|
+
}
|
|
77
|
+
>
|
|
78
|
+
Undo
|
|
79
|
+
</Button>
|
|
80
|
+
</>
|
|
81
|
+
) : (
|
|
82
|
+
<>
|
|
83
|
+
<Button
|
|
84
|
+
size="sm"
|
|
85
|
+
variant="outline"
|
|
86
|
+
className="h-11 md:h-8"
|
|
87
|
+
disabled={busy || p.forced}
|
|
88
|
+
title={
|
|
89
|
+
p.forced ? "Split because it spans repositories" : undefined
|
|
90
|
+
}
|
|
91
|
+
onClick={() => onEdit([...edits.merge, p.position], edits.drop)}
|
|
92
|
+
>
|
|
93
|
+
Merge into {CIRCLED[p.position - 2]}
|
|
94
|
+
</Button>
|
|
95
|
+
<Button
|
|
96
|
+
size="sm"
|
|
97
|
+
variant="ghost"
|
|
98
|
+
className="h-11 md:h-8"
|
|
99
|
+
disabled={busy}
|
|
100
|
+
onClick={() => onEdit(edits.merge, [...edits.drop, p.position])}
|
|
101
|
+
>
|
|
102
|
+
Drop
|
|
103
|
+
</Button>
|
|
104
|
+
</>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
</li>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// A plan waiting for a decision: how it splits, the plan itself, and
|
|
113
|
+
// Reject, Approve with a note, Approve.
|
|
114
|
+
export function PlanReview({ task }: { task: TaskView }) {
|
|
115
|
+
const approve = useApprovePlan();
|
|
116
|
+
const drop = useDropTask();
|
|
117
|
+
const planEdits = usePlanEdits();
|
|
118
|
+
const [showPlan, setShowPlan] = useState(false);
|
|
119
|
+
const [noting, setNoting] = useState(false);
|
|
120
|
+
const [note, setNote] = useState("");
|
|
121
|
+
const [rejecting, setRejecting] = useState(false);
|
|
122
|
+
const pieces = task.plan?.pieces;
|
|
123
|
+
const edits = {
|
|
124
|
+
merge:
|
|
125
|
+
pieces?.pieces.filter((p) => p.edit === "merge").map((p) => p.position) ??
|
|
126
|
+
[],
|
|
127
|
+
drop:
|
|
128
|
+
pieces?.pieces.filter((p) => p.edit === "drop").map((p) => p.position) ??
|
|
129
|
+
[],
|
|
130
|
+
};
|
|
131
|
+
const error =
|
|
132
|
+
approve.error?.message || drop.error?.message || planEdits.error?.message;
|
|
133
|
+
return (
|
|
134
|
+
<div className="space-y-3">
|
|
135
|
+
{pieces && pieces.pieces.length > 1 ? (
|
|
136
|
+
<div className="space-y-1">
|
|
137
|
+
<p className="label-mono text-muted-foreground">
|
|
138
|
+
Pieces ({pieces.pieces.length})
|
|
139
|
+
</p>
|
|
140
|
+
<ul className="divide-border/60 border-border/60 divide-y rounded-lg border">
|
|
141
|
+
{pieces.pieces.map((p) => (
|
|
142
|
+
<Piece
|
|
143
|
+
key={p.position}
|
|
144
|
+
p={p}
|
|
145
|
+
edits={edits}
|
|
146
|
+
busy={planEdits.isPending}
|
|
147
|
+
onEdit={(merge, dropped) =>
|
|
148
|
+
planEdits.mutate({ id: task.id, merge, drop: dropped })
|
|
149
|
+
}
|
|
150
|
+
/>
|
|
151
|
+
))}
|
|
152
|
+
</ul>
|
|
153
|
+
</div>
|
|
154
|
+
) : (
|
|
155
|
+
<p className="text-muted-foreground text-xs">1 piece</p>
|
|
156
|
+
)}
|
|
157
|
+
{pieces?.folded.map((f) => (
|
|
158
|
+
<p key={f.name} className="text-xs text-amber-700 dark:text-amber-300">
|
|
159
|
+
"{f.name}" folded into {CIRCLED[f.into - 1]}: {f.why}
|
|
160
|
+
</p>
|
|
161
|
+
))}
|
|
162
|
+
{pieces?.warnings.map((w) => (
|
|
163
|
+
<p key={w} className="text-xs text-amber-700 dark:text-amber-300">
|
|
164
|
+
{w}
|
|
165
|
+
</p>
|
|
166
|
+
))}
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
aria-expanded={showPlan}
|
|
170
|
+
className="text-primary min-h-11 text-xs font-medium md:min-h-8"
|
|
171
|
+
onClick={() => setShowPlan((v) => !v)}
|
|
172
|
+
>
|
|
173
|
+
{showPlan ? "Hide the plan" : "Show the plan"}
|
|
174
|
+
</button>
|
|
175
|
+
{showPlan && (
|
|
176
|
+
<div className="bg-foreground/[0.04] max-h-80 overflow-y-auto rounded-lg p-3 text-sm">
|
|
177
|
+
{task.plan?.text ? (
|
|
178
|
+
<Markdown text={task.plan.text} />
|
|
179
|
+
) : (
|
|
180
|
+
<p className="text-muted-foreground text-xs">
|
|
181
|
+
No plan file was written.
|
|
182
|
+
</p>
|
|
183
|
+
)}
|
|
184
|
+
{task.plan?.truncated && (
|
|
185
|
+
<p className="text-muted-foreground mt-2 font-mono text-[11px]">
|
|
186
|
+
Shortened; the whole plan is {task.plan.path} in the worktree.
|
|
187
|
+
</p>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
{noting && (
|
|
192
|
+
<div className="space-y-2">
|
|
193
|
+
<Textarea
|
|
194
|
+
rows={3}
|
|
195
|
+
value={note}
|
|
196
|
+
placeholder="A change to the plan (recorded as a scope change)"
|
|
197
|
+
onChange={(e) => setNote(e.target.value)}
|
|
198
|
+
/>
|
|
199
|
+
<Button
|
|
200
|
+
size="sm"
|
|
201
|
+
className="h-11 md:h-8"
|
|
202
|
+
disabled={approve.isPending || !note.trim()}
|
|
203
|
+
onClick={() =>
|
|
204
|
+
approve.mutate({ id: task.id, message: note.trim() })
|
|
205
|
+
}
|
|
206
|
+
>
|
|
207
|
+
Approve with this note
|
|
208
|
+
</Button>
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
<div className="flex flex-wrap gap-2">
|
|
212
|
+
{rejecting ? (
|
|
213
|
+
<Button
|
|
214
|
+
size="sm"
|
|
215
|
+
variant="destructive"
|
|
216
|
+
className="h-11 md:h-8"
|
|
217
|
+
disabled={drop.isPending}
|
|
218
|
+
onClick={() => drop.mutate(task.id)}
|
|
219
|
+
>
|
|
220
|
+
Drop this plan
|
|
221
|
+
</Button>
|
|
222
|
+
) : (
|
|
223
|
+
<Button
|
|
224
|
+
size="sm"
|
|
225
|
+
variant="ghost"
|
|
226
|
+
className="h-11 md:h-8"
|
|
227
|
+
onClick={() => setRejecting(true)}
|
|
228
|
+
>
|
|
229
|
+
Reject
|
|
230
|
+
</Button>
|
|
231
|
+
)}
|
|
232
|
+
<Button
|
|
233
|
+
size="sm"
|
|
234
|
+
variant="outline"
|
|
235
|
+
className="h-11 md:h-8"
|
|
236
|
+
aria-expanded={noting}
|
|
237
|
+
onClick={() => setNoting((v) => !v)}
|
|
238
|
+
>
|
|
239
|
+
Approve with note…
|
|
240
|
+
</Button>
|
|
241
|
+
<Button
|
|
242
|
+
size="sm"
|
|
243
|
+
className="h-11 md:h-8"
|
|
244
|
+
disabled={approve.isPending}
|
|
245
|
+
onClick={() => approve.mutate({ id: task.id })}
|
|
246
|
+
>
|
|
247
|
+
{approve.isPending ? "Approving..." : "Approve"}
|
|
248
|
+
</Button>
|
|
249
|
+
</div>
|
|
250
|
+
{error && <p className="text-destructive text-xs">{error}</p>}
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import {
|
|
5
|
+
ChefHat,
|
|
5
6
|
ExternalLink,
|
|
6
7
|
GitMerge,
|
|
7
8
|
KanbanSquare,
|
|
@@ -10,17 +11,13 @@ import {
|
|
|
10
11
|
import { Button } from "@/components/ui/button";
|
|
11
12
|
import type { TaskView } from "@/lib/tasks";
|
|
12
13
|
import { isFinished } from "@/lib/tasks/state";
|
|
13
|
-
import {
|
|
14
|
-
useApprovePlan,
|
|
15
|
-
useArmTask,
|
|
16
|
-
useDropTask,
|
|
17
|
-
useSignOffTask,
|
|
18
|
-
} from "@/data/tasks";
|
|
19
|
-
import { Markdown } from "@/components/Chat/Markdown";
|
|
14
|
+
import { useArmTask, useDropTask, useSignOffTask } from "@/data/tasks";
|
|
20
15
|
import { tmuxAttachActions } from "@/stores/tmuxAttach";
|
|
21
16
|
import { tasksUiActions } from "@/stores/tasksUi";
|
|
17
|
+
import { recipesUiActions } from "@/stores/recipesUi";
|
|
22
18
|
import { cn } from "@/lib/utils";
|
|
23
19
|
import { SimulatorIndicator } from "@/components/Simulator";
|
|
20
|
+
import { PlanReview } from "./PlanReview";
|
|
24
21
|
import { MoveTaskButton } from "./MoveTaskButton";
|
|
25
22
|
import { ResumeOnModel } from "./ResumeOnModel";
|
|
26
23
|
import { TASK_STATE as STATE } from "./task-state-labels";
|
|
@@ -30,8 +27,6 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
30
27
|
const signOff = useSignOffTask();
|
|
31
28
|
const drop = useDropTask();
|
|
32
29
|
const arm = useArmTask();
|
|
33
|
-
const approve = useApprovePlan();
|
|
34
|
-
const [showPlan, setShowPlan] = useState(false);
|
|
35
30
|
const [confirmDrop, setConfirmDrop] = useState(false);
|
|
36
31
|
const [moveError, setMoveError] = useState<string | null>(null);
|
|
37
32
|
const live = !isFinished(task.state);
|
|
@@ -39,7 +34,6 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
39
34
|
signOff.error?.message ||
|
|
40
35
|
drop.error?.message ||
|
|
41
36
|
arm.error?.message ||
|
|
42
|
-
approve.error?.message ||
|
|
43
37
|
moveError ||
|
|
44
38
|
task.hostError;
|
|
45
39
|
const settingUp = live && task.setup?.status === "running";
|
|
@@ -159,26 +153,6 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
159
153
|
{task.armed ? "Armed" : "Arm merge"}
|
|
160
154
|
</Button>
|
|
161
155
|
)}
|
|
162
|
-
{task.state === "plan-ready" && (
|
|
163
|
-
<>
|
|
164
|
-
<Button
|
|
165
|
-
size="sm"
|
|
166
|
-
variant="outline"
|
|
167
|
-
aria-expanded={showPlan}
|
|
168
|
-
onClick={() => setShowPlan((v) => !v)}
|
|
169
|
-
>
|
|
170
|
-
{showPlan ? "Hide plan" : "View plan"}
|
|
171
|
-
</Button>
|
|
172
|
-
<Button
|
|
173
|
-
size="sm"
|
|
174
|
-
className="h-11 md:h-8"
|
|
175
|
-
disabled={approve.isPending}
|
|
176
|
-
onClick={() => approve.mutate({ id: task.id })}
|
|
177
|
-
>
|
|
178
|
-
{approve.isPending ? "Approving..." : "Approve"}
|
|
179
|
-
</Button>
|
|
180
|
-
</>
|
|
181
|
-
)}
|
|
182
156
|
{task.state === "review" && (
|
|
183
157
|
<Button
|
|
184
158
|
size="sm"
|
|
@@ -190,20 +164,21 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
190
164
|
)}
|
|
191
165
|
</div>
|
|
192
166
|
)}
|
|
193
|
-
{live && task.state === "plan-ready" &&
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
167
|
+
{live && task.state === "plan-ready" && <PlanReview task={task} />}
|
|
168
|
+
{!live && !task.hostId && (
|
|
169
|
+
<div>
|
|
170
|
+
<Button
|
|
171
|
+
size="sm"
|
|
172
|
+
variant="ghost"
|
|
173
|
+
className="text-muted-foreground h-11 md:h-8"
|
|
174
|
+
onClick={() => {
|
|
175
|
+
recipesUiActions.saveSessionAs(task.id);
|
|
176
|
+
tasksUiActions.setPanelOpen(false);
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
<ChefHat className="h-3.5 w-3.5" />
|
|
180
|
+
Save as recipe
|
|
181
|
+
</Button>
|
|
207
182
|
</div>
|
|
208
183
|
)}
|
|
209
184
|
{error && <p className="text-destructive text-xs">{error}</p>}
|
|
@@ -16,6 +16,7 @@ import { newDraft } from "@/stores/drafts";
|
|
|
16
16
|
import { TaskRow } from "./TaskRow";
|
|
17
17
|
import { BoardTodoSection } from "@/components/LumifyHub";
|
|
18
18
|
import { StacksSection } from "@/components/Stacks";
|
|
19
|
+
import { FeaturesSection } from "@/components/Features";
|
|
19
20
|
|
|
20
21
|
export function TasksDialog() {
|
|
21
22
|
const { panelOpen } = useSnapshot(tasksUi);
|
|
@@ -57,6 +58,7 @@ export function TasksDialog() {
|
|
|
57
58
|
<TaskRow key={t.id} task={t} />
|
|
58
59
|
))}
|
|
59
60
|
</div>
|
|
61
|
+
<FeaturesSection />
|
|
60
62
|
<StacksSection />
|
|
61
63
|
<BoardTodoSection />
|
|
62
64
|
{done.length > 0 && (
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Bell } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
export function Block({
|
|
4
|
+
icon: Icon,
|
|
5
|
+
title,
|
|
6
|
+
detail,
|
|
7
|
+
children,
|
|
8
|
+
}: {
|
|
9
|
+
icon: typeof Bell;
|
|
10
|
+
title: string;
|
|
11
|
+
detail: string;
|
|
12
|
+
children: React.ReactNode;
|
|
13
|
+
}) {
|
|
14
|
+
return (
|
|
15
|
+
<section className="bg-muted/40 flex min-w-0 flex-col gap-3 rounded-lg px-3 py-3">
|
|
16
|
+
<div className="flex items-start gap-3">
|
|
17
|
+
<Icon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
|
18
|
+
<div className="min-w-0 flex-1">
|
|
19
|
+
<h3 className="text-sm font-medium">{title}</h3>
|
|
20
|
+
<p className="text-muted-foreground text-xs">{detail}</p>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
{children}
|
|
24
|
+
</section>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { ExternalLink, Globe } from "lucide-react";
|
|
5
|
+
import { CopyButton } from "@/components/Chat/CopyButton";
|
|
6
|
+
import { canManageDevices } from "@/components/Devices/DevicesPanel";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import { Input } from "@/components/ui/input";
|
|
9
|
+
import { Switch } from "@/components/ui/switch";
|
|
10
|
+
import {
|
|
11
|
+
useDevicesQuery,
|
|
12
|
+
useLinkConnect,
|
|
13
|
+
useNetworkQuery,
|
|
14
|
+
useUpdateNetwork,
|
|
15
|
+
} from "@/data/devices";
|
|
16
|
+
import { CONNECT_SITE, CONNECT_STATE, connectView } from "@/lib/connect/view";
|
|
17
|
+
import { Block } from "./Block";
|
|
18
|
+
|
|
19
|
+
const COMMAND = "agent-os connect";
|
|
20
|
+
|
|
21
|
+
function Command() {
|
|
22
|
+
return (
|
|
23
|
+
<div className="text-muted-foreground flex items-center gap-1 text-xs">
|
|
24
|
+
<span>Or run</span>
|
|
25
|
+
<code className="text-foreground font-mono">{COMMAND}</code>
|
|
26
|
+
<span>in a terminal.</span>
|
|
27
|
+
<CopyButton text={COMMAND} label="Copy command" />
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function LinkForm() {
|
|
33
|
+
const [code, setCode] = useState("");
|
|
34
|
+
const link = useLinkConnect();
|
|
35
|
+
const submit = () => {
|
|
36
|
+
if (code.trim() && !link.isPending) link.mutate(code.trim());
|
|
37
|
+
};
|
|
38
|
+
return (
|
|
39
|
+
<div className="flex min-w-0 flex-col gap-2">
|
|
40
|
+
<p className="text-muted-foreground text-xs">
|
|
41
|
+
Get a code at{" "}
|
|
42
|
+
<a
|
|
43
|
+
href={CONNECT_SITE}
|
|
44
|
+
target="_blank"
|
|
45
|
+
rel="noopener noreferrer"
|
|
46
|
+
className="text-foreground inline-flex items-center gap-1 underline underline-offset-2"
|
|
47
|
+
>
|
|
48
|
+
runagentos.com
|
|
49
|
+
<ExternalLink className="size-3" />
|
|
50
|
+
</a>{" "}
|
|
51
|
+
(Connect a machine), then paste it here.
|
|
52
|
+
</p>
|
|
53
|
+
<form
|
|
54
|
+
className="flex gap-2"
|
|
55
|
+
onSubmit={(e) => {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
submit();
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
<Input
|
|
61
|
+
value={code}
|
|
62
|
+
onChange={(e) => setCode(e.target.value)}
|
|
63
|
+
aria-label="Link code"
|
|
64
|
+
placeholder="Link code"
|
|
65
|
+
autoComplete="off"
|
|
66
|
+
autoCapitalize="off"
|
|
67
|
+
spellCheck={false}
|
|
68
|
+
className="min-h-11 flex-1 font-mono sm:min-h-9"
|
|
69
|
+
/>
|
|
70
|
+
<Button
|
|
71
|
+
type="submit"
|
|
72
|
+
variant="outline"
|
|
73
|
+
className="min-h-11 sm:min-h-9"
|
|
74
|
+
disabled={!code.trim() || link.isPending}
|
|
75
|
+
>
|
|
76
|
+
{link.isPending ? "Linking…" : "Link"}
|
|
77
|
+
</Button>
|
|
78
|
+
</form>
|
|
79
|
+
{link.error && (
|
|
80
|
+
<p className="text-destructive text-xs">{link.error.message}</p>
|
|
81
|
+
)}
|
|
82
|
+
<Command />
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** This machine's Connect: link it, then turn it on or off. */
|
|
88
|
+
export function ConnectBlock() {
|
|
89
|
+
// The running server follows a change within a couple of seconds.
|
|
90
|
+
const [wanted, setWanted] = useState<boolean | null>(null);
|
|
91
|
+
const network = useNetworkQuery(true, wanted === null ? false : 2000);
|
|
92
|
+
const devices = useDevicesQuery(true);
|
|
93
|
+
const update = useUpdateNetwork();
|
|
94
|
+
const canManage = canManageDevices(devices.data?.current.via);
|
|
95
|
+
const view = network.data ? connectView(network.data.connect) : null;
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (wanted === null) return;
|
|
99
|
+
const t = setTimeout(() => setWanted(null), 6000);
|
|
100
|
+
return () => clearTimeout(t);
|
|
101
|
+
}, [wanted]);
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<Block
|
|
105
|
+
icon={Globe}
|
|
106
|
+
title="Use it away from home"
|
|
107
|
+
detail="Connect gives this machine a private address at runagentos.com, so your phone reaches it from anywhere. Nothing to open on your router."
|
|
108
|
+
>
|
|
109
|
+
{network.isPending ? (
|
|
110
|
+
<div className="bg-muted/40 h-12 animate-pulse rounded-lg" />
|
|
111
|
+
) : network.isError || !view ? (
|
|
112
|
+
<p className="text-destructive text-xs">
|
|
113
|
+
{network.error?.message ?? "Couldn't read Connect."}
|
|
114
|
+
</p>
|
|
115
|
+
) : !view.linked ? (
|
|
116
|
+
canManage ? (
|
|
117
|
+
<LinkForm />
|
|
118
|
+
) : (
|
|
119
|
+
<div className="flex flex-col gap-2">
|
|
120
|
+
<p className="text-muted-foreground text-xs">
|
|
121
|
+
Link it on the machine running AgentOS.
|
|
122
|
+
</p>
|
|
123
|
+
<Command />
|
|
124
|
+
</div>
|
|
125
|
+
)
|
|
126
|
+
) : (
|
|
127
|
+
<div className="flex items-start gap-3">
|
|
128
|
+
<div className="min-w-0 flex-1">
|
|
129
|
+
<p className="text-sm font-medium">
|
|
130
|
+
Linked{" "}
|
|
131
|
+
<span className="text-muted-foreground font-normal">
|
|
132
|
+
· {CONNECT_STATE[network.data.connect.state]}
|
|
133
|
+
</span>
|
|
134
|
+
</p>
|
|
135
|
+
<p className="text-muted-foreground text-xs break-all">
|
|
136
|
+
{view.hostname ? (
|
|
137
|
+
<>
|
|
138
|
+
Reachable at{" "}
|
|
139
|
+
<span className="text-foreground font-mono">
|
|
140
|
+
https://{view.hostname}
|
|
141
|
+
</span>
|
|
142
|
+
</>
|
|
143
|
+
) : (
|
|
144
|
+
"The address appears once the tunnel starts."
|
|
145
|
+
)}
|
|
146
|
+
{network.data.connect.reason &&
|
|
147
|
+
network.data.connect.state !== "off"
|
|
148
|
+
? ` ${network.data.connect.reason.replace(/\.$/, "")}.`
|
|
149
|
+
: ""}
|
|
150
|
+
</p>
|
|
151
|
+
{update.error && (
|
|
152
|
+
<p className="text-destructive text-xs">{update.error.message}</p>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
<Switch
|
|
156
|
+
checked={wanted ?? view.on}
|
|
157
|
+
disabled={!view.canToggle || !canManage}
|
|
158
|
+
onCheckedChange={(connect) => {
|
|
159
|
+
setWanted(connect);
|
|
160
|
+
update.mutate({ connect });
|
|
161
|
+
}}
|
|
162
|
+
className="mt-0.5"
|
|
163
|
+
aria-label="Connect"
|
|
164
|
+
/>
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
</Block>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { Bell, Laptop, Smartphone } from "lucide-react";
|
|
5
|
+
import { Block } from "./Block";
|
|
6
|
+
import { ConnectBlock } from "./ConnectBlock";
|
|
5
7
|
import { AddDevicePanel } from "@/components/Devices/AddDevicePanel";
|
|
6
8
|
import {
|
|
7
9
|
NotificationsSection,
|
|
@@ -9,31 +11,6 @@ import {
|
|
|
9
11
|
} from "@/components/Settings/NotificationsSection";
|
|
10
12
|
import { Button } from "@/components/ui/button";
|
|
11
13
|
|
|
12
|
-
function Block({
|
|
13
|
-
icon: Icon,
|
|
14
|
-
title,
|
|
15
|
-
detail,
|
|
16
|
-
children,
|
|
17
|
-
}: {
|
|
18
|
-
icon: typeof Bell;
|
|
19
|
-
title: string;
|
|
20
|
-
detail: string;
|
|
21
|
-
children: React.ReactNode;
|
|
22
|
-
}) {
|
|
23
|
-
return (
|
|
24
|
-
<section className="bg-muted/40 flex min-w-0 flex-col gap-3 rounded-lg px-3 py-3">
|
|
25
|
-
<div className="flex items-start gap-3">
|
|
26
|
-
<Icon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
|
27
|
-
<div className="min-w-0 flex-1">
|
|
28
|
-
<h3 className="text-sm font-medium">{title}</h3>
|
|
29
|
-
<p className="text-muted-foreground text-xs">{detail}</p>
|
|
30
|
-
</div>
|
|
31
|
-
</div>
|
|
32
|
-
{children}
|
|
33
|
-
</section>
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
14
|
/**
|
|
38
15
|
* Pair a phone, link another machine, set up notifications. Every part is
|
|
39
16
|
* optional and lives in Settings too.
|
|
@@ -51,7 +28,7 @@ export function DevicesStep({
|
|
|
51
28
|
<Block
|
|
52
29
|
icon={Smartphone}
|
|
53
30
|
title="Pair your phone"
|
|
54
|
-
detail="Scan a code to use AgentOS from your phone."
|
|
31
|
+
detail="Scan a code to use AgentOS from your phone on this network."
|
|
55
32
|
>
|
|
56
33
|
{pairing ? (
|
|
57
34
|
<AddDevicePanel onDone={() => setPairing(false)} />
|
|
@@ -65,6 +42,7 @@ export function DevicesStep({
|
|
|
65
42
|
</Button>
|
|
66
43
|
)}
|
|
67
44
|
</Block>
|
|
45
|
+
<ConnectBlock />
|
|
68
46
|
<Block
|
|
69
47
|
icon={Laptop}
|
|
70
48
|
title="Link another machine"
|
package/data/devices/queries.ts
CHANGED
|
@@ -26,12 +26,32 @@ export function useDevicesQuery(enabled = true) {
|
|
|
26
26
|
});
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export function useNetworkQuery(
|
|
29
|
+
export function useNetworkQuery(
|
|
30
|
+
enabled = true,
|
|
31
|
+
refetchMs: number | false = false
|
|
32
|
+
) {
|
|
30
33
|
return useQuery({
|
|
31
34
|
queryKey: deviceKeys.network(),
|
|
32
35
|
queryFn: async () =>
|
|
33
36
|
json<NetworkState>(await fetch("/api/devices/network")),
|
|
34
37
|
enabled,
|
|
38
|
+
refetchInterval: refetchMs,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Links this machine to Connect with a code from runagentos.com. */
|
|
43
|
+
export function useLinkConnect() {
|
|
44
|
+
const queryClient = useQueryClient();
|
|
45
|
+
return useMutation({
|
|
46
|
+
mutationFn: async (code: string) =>
|
|
47
|
+
json<NetworkState>(
|
|
48
|
+
await fetch("/api/devices/connect", {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { "Content-Type": "application/json" },
|
|
51
|
+
body: JSON.stringify({ code }),
|
|
52
|
+
})
|
|
53
|
+
),
|
|
54
|
+
onSuccess: (data) => queryClient.setQueryData(deviceKeys.network(), data),
|
|
35
55
|
});
|
|
36
56
|
}
|
|
37
57
|
|