@saadnvd1/agent-os 0.3.519 → 0.3.521

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 (50) hide show
  1. package/README.md +26 -0
  2. package/app/api/bus/spawn/route.ts +4 -0
  3. package/app/api/tasks/[id]/approve-plan/route.ts +22 -0
  4. package/app/api/tasks/route.ts +2 -0
  5. package/app/api/welcome/route.ts +22 -0
  6. package/app/page.tsx +6 -0
  7. package/app/welcome/page.tsx +6 -0
  8. package/components/Projects/AddProject/AddProjectDialog.tsx +2 -2
  9. package/components/Settings/SettingsDialog.tsx +9 -0
  10. package/components/Settings/SetupSection.tsx +37 -0
  11. package/components/Tasks/TaskRow.tsx +46 -1
  12. package/components/Tasks/task-state-labels.ts +1 -0
  13. package/components/Welcome/AgentList.tsx +73 -0
  14. package/components/Welcome/AgentRow.tsx +102 -0
  15. package/components/Welcome/ProjectStep.tsx +100 -0
  16. package/components/Welcome/SetupTerminal.tsx +64 -0
  17. package/components/Welcome/WelcomeDialog.tsx +153 -0
  18. package/data/agents/index.ts +16 -1
  19. package/data/tasks/index.ts +12 -0
  20. package/data/welcome/index.ts +36 -0
  21. package/hooks/useWelcome.ts +70 -0
  22. package/lib/agents/probe.ts +27 -0
  23. package/lib/agents/setup-commands.ts +54 -0
  24. package/lib/db/migrations.ts +29 -0
  25. package/lib/db/types.ts +3 -1
  26. package/lib/lumifyhub/lists.ts +1 -0
  27. package/lib/models/plan-step.ts +116 -32
  28. package/lib/models/roles.ts +3 -1
  29. package/lib/orchestrator/act.ts +24 -2
  30. package/lib/orchestrator/brief.ts +3 -1
  31. package/lib/orchestrator/conditions.ts +1 -0
  32. package/lib/orchestrator/describe.ts +1 -1
  33. package/lib/orchestrator/facts.ts +11 -2
  34. package/lib/orchestrator/serve.ts +8 -0
  35. package/lib/orchestrator/tool-names.ts +1 -0
  36. package/lib/orchestrator/tool-schemas.ts +18 -0
  37. package/lib/orchestrator/tools.ts +3 -1
  38. package/lib/settings-url.ts +1 -0
  39. package/lib/tasks/index.ts +28 -7
  40. package/lib/tasks/move-guard.ts +2 -0
  41. package/lib/tasks/move.ts +5 -1
  42. package/lib/tasks/plan-view.ts +6 -1
  43. package/lib/tasks/queue.ts +6 -2
  44. package/lib/tasks/start.ts +1 -1
  45. package/lib/tasks/state.ts +7 -1
  46. package/lib/welcome-url.ts +33 -0
  47. package/lib/welcome.ts +88 -0
  48. package/package.json +1 -1
  49. package/stores/addProject.ts +19 -1
  50. package/stores/welcomeUi.ts +26 -0
package/README.md CHANGED
@@ -213,6 +213,17 @@ its WSL note, and Windows reached it at both `localhost:3011` and
213
213
  `wslview`/`explorer.exe`, mirrored networking, `agent-os enable` under
214
214
  systemd, the idle-timeout settings above, and Debian or other distros.
215
215
 
216
+ ### First run
217
+
218
+ On a fresh install (no sessions, no projects of your own) the web app opens a
219
+ short setup at `/?welcome`: it shows each agent CLI as ready, needing a
220
+ sign-in, or missing, installs or signs one in from an embedded terminal, adds
221
+ a first project (a folder, a clone or an empty one), then opens a session in
222
+ it. Every step after the first can be skipped. It is remembered on the server,
223
+ so a phone you pair later doesn't see it, and an install that already has work
224
+ never does. Reopen it from Settings → Setup (**Run setup again**) or at
225
+ `/welcome`; `AGENTOS_SKIP_WELCOME=1` turns it off.
226
+
216
227
  ### Supported agents
217
228
 
218
229
  | Agent | Chat | Resume | Fork | Auto-approve |
@@ -539,6 +550,21 @@ and **Usage → Compare** splits cost into planned and not. When the plan step
539
550
  finishes, the orchestrator gets one event with the plan file's path and a
540
551
  3-5 line summary; the handoff to implementation stays automatic.
541
552
 
553
+ **Plan only.** A task started with `plan_only` (`start_task`'s `plan_only`,
554
+ `aos task --plan-only`, or `plan_only: true` on `POST /api/tasks`) writes its
555
+ plan on the planning model and then stops: no model switch, no implement
556
+ prompt, even when one model does every role. It sits in a **Plan ready**
557
+ state (`plan_phase` `awaiting`, stored on the task, so it survives a restart)
558
+ that isn't blocked, doesn't count as needing you, and holds no agent process
559
+ (its worker is released; the conversation resumes on approval). The
560
+ orchestrator gets one event with the plan's path, a summary and that the task
561
+ is waiting. **Approve** on the task row (plan viewable there), `POST
562
+ /api/tasks/<id>/approve-plan`, or the orchestrator's `approve_plan(task,
563
+ message?)` continues it on the implementation model, once; a message amends
564
+ the plan and is recorded on the task's brief as a scope change, so the review
565
+ judges against it. Reject with **Drop**. A chat task on this machine only; it
566
+ can't move until approved.
567
+
542
568
  **Per task.** The task draft has a **Model** chip ("Routed by role", or one
543
569
  model); `POST /api/tasks` and the orchestrator's `start_task` take `model`, as
544
570
  `"provider/model"` for the task's own roles or a map such as
@@ -29,12 +29,15 @@ export async function POST(request: NextRequest) {
29
29
  on,
30
30
  after,
31
31
  view,
32
+ planOnly,
32
33
  from,
33
34
  } = await request.json();
34
35
  const startedBy = orchestratorFrom(from);
35
36
  const given = typeof name === "string" ? name : undefined;
36
37
  if (after !== undefined && mode !== "task")
37
38
  throw new Error("--after works for tasks only");
39
+ if (planOnly !== undefined && mode !== "task")
40
+ throw new Error("--plan-only works for tasks only");
38
41
  if (mode === "task") {
39
42
  const task = {
40
43
  projectId: findProject(String(project ?? "")).id,
@@ -43,6 +46,7 @@ export async function POST(request: NextRequest) {
43
46
  model,
44
47
  hostId: on ? hostIdNamed(String(on)) : undefined,
45
48
  view: startView(view),
49
+ planOnly: planOnly === true ? true : undefined,
46
50
  startedBy,
47
51
  };
48
52
  const out = await startOrQueue(
@@ -0,0 +1,22 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { approvePlanNow } from "@/lib/models/plan-step";
3
+
4
+ // Approves a plan-only task's plan: it continues on the implementation
5
+ // model. An optional message amends the plan (recorded as a scope change).
6
+ export async function POST(
7
+ request: NextRequest,
8
+ { params }: { params: Promise<{ id: string }> }
9
+ ) {
10
+ try {
11
+ const body = await request.json().catch(() => ({}));
12
+ const message =
13
+ typeof body?.message === "string" && body.message.trim()
14
+ ? body.message.trim().slice(0, 8000)
15
+ : undefined;
16
+ const out = await approvePlanNow((await params).id, message);
17
+ return NextResponse.json({ success: true, model: out.ref });
18
+ } catch (error) {
19
+ const message = error instanceof Error ? error.message : String(error);
20
+ return NextResponse.json({ error: message }, { status: 409 });
21
+ }
22
+ }
@@ -36,6 +36,7 @@ export async function POST(request: NextRequest) {
36
36
  view,
37
37
  plan,
38
38
  plan_reason,
39
+ plan_only,
39
40
  } = await request.json();
40
41
  if (id !== undefined && !isSessionId(id)) throw new Error("Bad task id");
41
42
  const pid =
@@ -48,6 +49,7 @@ export async function POST(request: NextRequest) {
48
49
  // Checked now, so a queued task can't fail on it later.
49
50
  model: parseOverride(model) ?? undefined,
50
51
  plan: typeof plan === "boolean" ? plan : undefined,
52
+ planOnly: plan_only === true ? true : undefined,
51
53
  planReason:
52
54
  typeof plan_reason === "string" && plan_reason.trim()
53
55
  ? plan_reason.trim().slice(0, 200)
@@ -0,0 +1,22 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { demoMode } from "@/lib/security/demo";
3
+ import { completeWelcome, readWelcomeGate } from "@/lib/welcome";
4
+
5
+ // Whether the first-run welcome should open on this server. An install
6
+ // already in use is marked done here, so emptying it later doesn't bring it back.
7
+ export async function GET() {
8
+ return NextResponse.json(readWelcomeGate(demoMode()));
9
+ }
10
+
11
+ // { action: "complete" }: setup finished or skipped.
12
+ export async function POST(request: NextRequest) {
13
+ try {
14
+ const body = await request.json();
15
+ if (body?.action !== "complete") throw new Error("Unknown action");
16
+ completeWelcome(new Date().toISOString(), undefined, true);
17
+ return NextResponse.json(readWelcomeGate(demoMode()));
18
+ } catch (error) {
19
+ const message = error instanceof Error ? error.message : String(error);
20
+ return NextResponse.json({ error: message }, { status: 400 });
21
+ }
22
+ }
package/app/page.tsx CHANGED
@@ -78,6 +78,11 @@ const SettingsDialog = dynamic(
78
78
  ),
79
79
  { ssr: false }
80
80
  );
81
+ const WelcomeDialog = dynamic(
82
+ () =>
83
+ import("@/components/Welcome/WelcomeDialog").then((m) => m.WelcomeDialog),
84
+ { ssr: false }
85
+ );
81
86
  const MessagesDialog = dynamic(
82
87
  () => import("@/components/Bus/MessagesDialog").then((m) => m.MessagesDialog),
83
88
  { ssr: false }
@@ -122,6 +127,7 @@ const AppDialogs = memo(function AppDialogs() {
122
127
  return (
123
128
  <>
124
129
  <TasksDialog />
130
+ <WelcomeDialog />
125
131
  <AddProjectDialog />
126
132
  <MessagesDialog />
127
133
  <ArchivedDialog />
@@ -0,0 +1,6 @@
1
+ import { redirect } from "next/navigation";
2
+
3
+ // Reopens the first-run welcome.
4
+ export default function WelcomePage() {
5
+ redirect("/?welcome=agents");
6
+ }
@@ -29,8 +29,8 @@ export function AddProjectDialog() {
29
29
  const { open } = useSnapshot(addProjectUi);
30
30
  const hostNames = useHostNames();
31
31
  const done = (projectId: string) => {
32
- addProjectActions.close();
33
- newDraft({ kind: "project", projectId });
32
+ if (!addProjectActions.finish(projectId))
33
+ newDraft({ kind: "project", projectId });
34
34
  };
35
35
 
36
36
  return (
@@ -11,6 +11,7 @@ import {
11
11
  Cpu,
12
12
  GitMerge,
13
13
  LayoutGrid,
14
+ ListChecks,
14
15
  Smartphone,
15
16
  X,
16
17
  type LucideIcon,
@@ -34,6 +35,7 @@ import { SETTINGS_SECTIONS, type SettingsSection } from "@/lib/settings-url";
34
35
  import { cn } from "@/lib/utils";
35
36
  import { schedulesUiActions } from "@/stores/schedulesUi";
36
37
  import { settingsUi, settingsUiActions } from "@/stores/settingsUi";
38
+ import { SetupSection } from "./SetupSection";
37
39
  import {
38
40
  NotificationsSection,
39
41
  type BrowserNotifications,
@@ -78,6 +80,11 @@ export const SECTION_INFO: Record<
78
80
  hint: "Where sessions spent tokens and repeated work",
79
81
  icon: ClipboardList,
80
82
  },
83
+ setup: {
84
+ label: "Setup",
85
+ hint: "Agents on this machine, first-run setup",
86
+ icon: ListChecks,
87
+ },
81
88
  };
82
89
 
83
90
  function SectionBody({
@@ -102,6 +109,8 @@ function SectionBody({
102
109
  return <SchedulesPanel />;
103
110
  case "audits":
104
111
  return <AuditsPanel />;
112
+ case "setup":
113
+ return <SetupSection />;
105
114
  }
106
115
  }
107
116
 
@@ -0,0 +1,37 @@
1
+ "use client";
2
+
3
+ import { Button } from "@/components/ui/button";
4
+ import { AgentList } from "@/components/Welcome/AgentList";
5
+ import { settingsUiActions } from "@/stores/settingsUi";
6
+ import { welcomeUiActions } from "@/stores/welcomeUi";
7
+
8
+ // What this machine has for running agents, and the first-run setup again.
9
+ export function SetupSection() {
10
+ return (
11
+ <div className="flex flex-col gap-4">
12
+ <div className="bg-muted/40 flex items-center gap-3 rounded-lg px-3 py-2.5">
13
+ <div className="min-w-0 flex-1">
14
+ <p className="text-sm font-medium">First-run setup</p>
15
+ <p className="text-muted-foreground text-xs">
16
+ Agents, your first project, and a first session.
17
+ </p>
18
+ </div>
19
+ <Button
20
+ size="sm"
21
+ variant="outline"
22
+ className="min-h-11 sm:min-h-8"
23
+ onClick={() => {
24
+ settingsUiActions.close();
25
+ welcomeUiActions.open("agents");
26
+ }}
27
+ >
28
+ Run setup again
29
+ </Button>
30
+ </div>
31
+ <div className="flex flex-col gap-2">
32
+ <h3 className="text-sm font-medium">Agents on this machine</h3>
33
+ <AgentList />
34
+ </div>
35
+ </div>
36
+ );
37
+ }
@@ -10,7 +10,13 @@ import {
10
10
  import { Button } from "@/components/ui/button";
11
11
  import type { TaskView } from "@/lib/tasks";
12
12
  import { isFinished } from "@/lib/tasks/state";
13
- import { useArmTask, useDropTask, useSignOffTask } from "@/data/tasks";
13
+ import {
14
+ useApprovePlan,
15
+ useArmTask,
16
+ useDropTask,
17
+ useSignOffTask,
18
+ } from "@/data/tasks";
19
+ import { Markdown } from "@/components/Chat/Markdown";
14
20
  import { tmuxAttachActions } from "@/stores/tmuxAttach";
15
21
  import { tasksUiActions } from "@/stores/tasksUi";
16
22
  import { cn } from "@/lib/utils";
@@ -23,6 +29,8 @@ export function TaskRow({ task }: { task: TaskView }) {
23
29
  const signOff = useSignOffTask();
24
30
  const drop = useDropTask();
25
31
  const arm = useArmTask();
32
+ const approve = useApprovePlan();
33
+ const [showPlan, setShowPlan] = useState(false);
26
34
  const [confirmDrop, setConfirmDrop] = useState(false);
27
35
  const [moveError, setMoveError] = useState<string | null>(null);
28
36
  const live = !isFinished(task.state);
@@ -30,6 +38,7 @@ export function TaskRow({ task }: { task: TaskView }) {
30
38
  signOff.error?.message ||
31
39
  drop.error?.message ||
32
40
  arm.error?.message ||
41
+ approve.error?.message ||
33
42
  moveError ||
34
43
  task.hostError;
35
44
  const settingUp = live && task.setup?.status === "running";
@@ -148,6 +157,26 @@ export function TaskRow({ task }: { task: TaskView }) {
148
157
  {task.armed ? "Armed" : "Arm merge"}
149
158
  </Button>
150
159
  )}
160
+ {task.state === "plan-ready" && (
161
+ <>
162
+ <Button
163
+ size="sm"
164
+ variant="outline"
165
+ aria-expanded={showPlan}
166
+ onClick={() => setShowPlan((v) => !v)}
167
+ >
168
+ {showPlan ? "Hide plan" : "View plan"}
169
+ </Button>
170
+ <Button
171
+ size="sm"
172
+ className="h-11 md:h-8"
173
+ disabled={approve.isPending}
174
+ onClick={() => approve.mutate({ id: task.id })}
175
+ >
176
+ {approve.isPending ? "Approving..." : "Approve"}
177
+ </Button>
178
+ </>
179
+ )}
151
180
  {task.state === "review" && (
152
181
  <Button
153
182
  size="sm"
@@ -159,6 +188,22 @@ export function TaskRow({ task }: { task: TaskView }) {
159
188
  )}
160
189
  </div>
161
190
  )}
191
+ {live && task.state === "plan-ready" && showPlan && (
192
+ <div className="bg-foreground/[0.04] max-h-80 overflow-y-auto rounded-lg p-3 text-sm">
193
+ {task.plan?.text ? (
194
+ <Markdown text={task.plan.text} />
195
+ ) : (
196
+ <p className="text-muted-foreground text-xs">
197
+ No plan file was written.
198
+ </p>
199
+ )}
200
+ {task.plan?.truncated && (
201
+ <p className="text-muted-foreground mt-2 font-mono text-[11px]">
202
+ Shortened; the whole plan is {task.plan.path} in the worktree.
203
+ </p>
204
+ )}
205
+ </div>
206
+ )}
162
207
  {error && <p className="text-destructive text-xs">{error}</p>}
163
208
  </div>
164
209
  );
@@ -8,6 +8,7 @@ export const TASK_STATE: Record<TaskState, { label: string; tone: string }> = {
8
8
  tone: "text-amber-600 dark:text-amber-400",
9
9
  },
10
10
  blocked: { label: "Blocked", tone: "text-amber-600 dark:text-amber-400" },
11
+ "plan-ready": { label: "Plan ready", tone: "text-primary" },
11
12
  review: { label: "Ready for review", tone: "text-primary" },
12
13
  "checks-failing": { label: "Checks failing", tone: "text-destructive" },
13
14
  exited: { label: "Agent exited", tone: "text-amber-600 dark:text-amber-400" },
@@ -0,0 +1,73 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { RefreshCw } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { useAgentStatusQuery, useRecheckAgents } from "@/data/agents";
7
+ import { FEATURED_AGENTS } from "@/lib/agents/setup-commands";
8
+ import { getAllProviderDefinitions } from "@/lib/providers/registry";
9
+ import { AgentRow, type AgentRun } from "./AgentRow";
10
+ import { SetupTerminal } from "./SetupTerminal";
11
+
12
+ /**
13
+ * Every agent CLI on this machine with what it needs, and an embedded
14
+ * terminal to install or sign one in. Checks again when it opens and when a
15
+ * terminal is closed, never on a timer.
16
+ */
17
+ export function AgentList() {
18
+ const { data: agents } = useAgentStatusQuery();
19
+ const recheck = useRecheckAgents();
20
+ const [run, setRun] = useState<(AgentRun & { key: number }) | null>(null);
21
+ const [all, setAll] = useState(false);
22
+ const { mutate } = recheck;
23
+
24
+ useEffect(() => mutate(), [mutate]);
25
+
26
+ const providers = getAllProviderDefinitions().filter((p) => p.cli);
27
+ const shown = providers.filter((p) => all || FEATURED_AGENTS.includes(p.id));
28
+ const hidden = providers.length - shown.length;
29
+
30
+ return (
31
+ <div className="flex flex-col gap-3">
32
+ <ul className="flex flex-col gap-2">
33
+ {shown.map((p) => (
34
+ <AgentRow
35
+ key={p.id}
36
+ provider={p}
37
+ probe={agents?.[p.id]}
38
+ onRun={(r) => setRun({ ...r, key: Date.now() })}
39
+ />
40
+ ))}
41
+ </ul>
42
+ {run && (
43
+ <SetupTerminal
44
+ key={run.key}
45
+ title={run.title}
46
+ command={run.command}
47
+ onDone={() => {
48
+ setRun(null);
49
+ mutate();
50
+ }}
51
+ />
52
+ )}
53
+ <div className="flex items-center justify-between gap-2">
54
+ {hidden > 0 ? (
55
+ <Button size="sm" variant="ghost" onClick={() => setAll(true)}>
56
+ Show {hidden} more
57
+ </Button>
58
+ ) : (
59
+ <span />
60
+ )}
61
+ <Button
62
+ size="sm"
63
+ variant="ghost"
64
+ disabled={recheck.isPending}
65
+ onClick={() => mutate()}
66
+ >
67
+ <RefreshCw className={recheck.isPending ? "animate-spin" : ""} />
68
+ Check again
69
+ </Button>
70
+ </div>
71
+ </div>
72
+ );
73
+ }
@@ -0,0 +1,102 @@
1
+ "use client";
2
+
3
+ import { Check, Download, LogIn } from "lucide-react";
4
+ import { Badge } from "@/components/ui/badge";
5
+ import { Button } from "@/components/ui/button";
6
+ import type { AgentProbe } from "@/lib/agents/probe";
7
+ import { agentState, SETUP_COMMANDS } from "@/lib/agents/setup-commands";
8
+ import type { ProviderDefinition } from "@/lib/providers/registry";
9
+
10
+ const LABEL = {
11
+ missing: "Not installed",
12
+ "needs-login": "Needs sign-in",
13
+ ready: "Ready",
14
+ installed: "Installed",
15
+ } as const;
16
+
17
+ export type AgentRun = { title: string; command: string };
18
+
19
+ /** One agent CLI: its state, and the install or sign-in it needs. */
20
+ export function AgentRow({
21
+ provider,
22
+ probe,
23
+ onRun,
24
+ }: {
25
+ provider: ProviderDefinition;
26
+ probe: AgentProbe | undefined;
27
+ onRun: (run: AgentRun) => void;
28
+ }) {
29
+ const state = probe ? agentState(probe) : null;
30
+ const commands = SETUP_COMMANDS[provider.id];
31
+ return (
32
+ <li className="bg-muted/40 flex min-h-11 items-center gap-3 rounded-lg px-3 py-2">
33
+ <div className="min-w-0 flex-1">
34
+ <p className="truncate text-sm font-medium">{provider.name}</p>
35
+ <p className="text-muted-foreground truncate text-xs">
36
+ {probe?.version
37
+ ? `v${probe.version}`
38
+ : (probe?.hint ?? provider.description)}
39
+ </p>
40
+ </div>
41
+ {!state && <Badge variant="outline">Checking</Badge>}
42
+ {state === "ready" && (
43
+ <Badge variant="success">
44
+ <Check className="mr-1 size-3" />
45
+ {LABEL.ready}
46
+ </Badge>
47
+ )}
48
+ {state === "installed" && <Badge variant="outline">{LABEL[state]}</Badge>}
49
+ {state === "needs-login" && (
50
+ <>
51
+ <Badge variant="secondary">{LABEL[state]}</Badge>
52
+ {commands?.signIn && (
53
+ <Button
54
+ size="sm"
55
+ variant="outline"
56
+ className="min-h-11 sm:min-h-8"
57
+ onClick={() =>
58
+ onRun({
59
+ title: `Sign in to ${provider.name}`,
60
+ command: commands.signIn!,
61
+ })
62
+ }
63
+ >
64
+ <LogIn />
65
+ Sign in
66
+ </Button>
67
+ )}
68
+ </>
69
+ )}
70
+ {state === "missing" &&
71
+ (commands?.install ? (
72
+ <Button
73
+ size="sm"
74
+ variant="outline"
75
+ className="min-h-11 sm:min-h-8"
76
+ onClick={() =>
77
+ onRun({
78
+ title: `Install ${provider.name}`,
79
+ command: commands.install!,
80
+ })
81
+ }
82
+ >
83
+ <Download />
84
+ Install
85
+ </Button>
86
+ ) : commands?.docs ? (
87
+ <Button
88
+ size="sm"
89
+ variant="outline"
90
+ className="min-h-11 sm:min-h-8"
91
+ asChild
92
+ >
93
+ <a href={commands.docs} target="_blank" rel="noreferrer">
94
+ How to install
95
+ </a>
96
+ </Button>
97
+ ) : (
98
+ <Badge variant="outline">{LABEL.missing}</Badge>
99
+ ))}
100
+ </li>
101
+ );
102
+ }
@@ -0,0 +1,100 @@
1
+ "use client";
2
+
3
+ import { FolderGit2, FolderOpen, FolderPlus, Check } from "lucide-react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { useProjectsQuery } from "@/data/projects/queries";
6
+ import { cn } from "@/lib/utils";
7
+ import { addProjectActions, type AddProjectKind } from "@/stores/addProject";
8
+ import { welcomeUi, welcomeUiActions } from "@/stores/welcomeUi";
9
+ import { useSnapshot } from "valtio";
10
+
11
+ const WAYS: {
12
+ kind: AddProjectKind;
13
+ title: string;
14
+ hint: string;
15
+ icon: typeof FolderOpen;
16
+ }[] = [
17
+ {
18
+ kind: "folder",
19
+ title: "Open a folder",
20
+ hint: "A project already on this machine",
21
+ icon: FolderOpen,
22
+ },
23
+ {
24
+ kind: "clone",
25
+ title: "Clone a repo",
26
+ hint: "From a Git URL",
27
+ icon: FolderGit2,
28
+ },
29
+ {
30
+ kind: "name",
31
+ title: "Start an empty project",
32
+ hint: "A new folder with a name",
33
+ icon: FolderPlus,
34
+ },
35
+ ];
36
+
37
+ /** Add the project the first session will open in, or pick one already there. */
38
+ export function ProjectStep() {
39
+ const { projectId } = useSnapshot(welcomeUi);
40
+ const { data: projects = [] } = useProjectsQuery();
41
+ const own = projects.filter((p) => !p.is_uncategorized);
42
+
43
+ const add = (kind: AddProjectKind) =>
44
+ addProjectActions.show(kind, "local", (id) => {
45
+ welcomeUiActions.setProject(id);
46
+ welcomeUiActions.go("finish");
47
+ });
48
+
49
+ return (
50
+ <div className="flex flex-col gap-3">
51
+ {own.length > 0 && (
52
+ <ul className="flex flex-col gap-2">
53
+ {own.map((p) => (
54
+ <li key={p.id}>
55
+ <button
56
+ type="button"
57
+ onClick={() => {
58
+ welcomeUiActions.setProject(p.id);
59
+ welcomeUiActions.go("finish");
60
+ }}
61
+ className={cn(
62
+ "bg-muted/40 hover:bg-accent flex min-h-11 w-full items-center gap-3 rounded-lg px-3 py-2 text-left",
63
+ projectId === p.id && "ring-primary/40 ring-2"
64
+ )}
65
+ >
66
+ <span className="min-w-0 flex-1">
67
+ <span className="block truncate text-sm font-medium">
68
+ {p.name}
69
+ </span>
70
+ <span className="text-muted-foreground block truncate text-xs">
71
+ {p.working_directory}
72
+ </span>
73
+ </span>
74
+ {projectId === p.id && <Check className="size-4" />}
75
+ </button>
76
+ </li>
77
+ ))}
78
+ </ul>
79
+ )}
80
+ <div className="flex flex-col gap-2">
81
+ {WAYS.map((w) => (
82
+ <Button
83
+ key={w.kind}
84
+ variant="outline"
85
+ className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
86
+ onClick={() => add(w.kind)}
87
+ >
88
+ <w.icon />
89
+ <span className="min-w-0">
90
+ <span className="block text-sm font-medium">{w.title}</span>
91
+ <span className="text-muted-foreground block text-xs font-normal">
92
+ {w.hint}
93
+ </span>
94
+ </span>
95
+ </Button>
96
+ ))}
97
+ </div>
98
+ </div>
99
+ );
100
+ }
@@ -0,0 +1,64 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef } from "react";
4
+ import dynamic from "next/dynamic";
5
+ import { Button } from "@/components/ui/button";
6
+ import type { TerminalHandle } from "@/components/Terminal";
7
+
8
+ const Terminal = dynamic(
9
+ () => import("@/components/Terminal").then((m) => m.Terminal),
10
+ { ssr: false }
11
+ );
12
+
13
+ /**
14
+ * A terminal on this machine with one setup command typed at the prompt, for
15
+ * the user to read and run with Enter. It goes into their own shell over the
16
+ * existing terminal socket; the server runs nothing.
17
+ */
18
+ export function SetupTerminal({
19
+ title,
20
+ command,
21
+ onDone,
22
+ }: {
23
+ title: string;
24
+ command: string;
25
+ onDone: () => void;
26
+ }) {
27
+ const ref = useRef<TerminalHandle | null>(null);
28
+ const typed = useRef(false);
29
+ const box = useRef<HTMLDivElement>(null);
30
+
31
+ // On a phone it opens below the list, out of sight.
32
+ useEffect(() => {
33
+ box.current?.scrollIntoView?.({ block: "nearest", behavior: "smooth" });
34
+ }, []);
35
+
36
+ const connected = useCallback(() => {
37
+ if (typed.current) return;
38
+ typed.current = true;
39
+ setTimeout(() => {
40
+ ref.current?.sendInput("\x15");
41
+ // Typed, not run: the user reads it and presses Enter.
42
+ setTimeout(() => ref.current?.sendInput(command), 50);
43
+ }, 100);
44
+ }, [command]);
45
+
46
+ return (
47
+ <div
48
+ ref={box}
49
+ className="bg-muted/30 hairline flex flex-col overflow-hidden rounded-lg"
50
+ >
51
+ <div className="flex items-center justify-between gap-2 px-3 py-1.5">
52
+ <span className="text-muted-foreground truncate text-xs">
53
+ {title}. Press Enter to run it.
54
+ </span>
55
+ <Button size="sm" onClick={onDone}>
56
+ Done, check again
57
+ </Button>
58
+ </div>
59
+ <div className="h-64 min-h-0">
60
+ <Terminal ref={ref} onConnected={connected} />
61
+ </div>
62
+ </div>
63
+ );
64
+ }