@saadnvd1/agent-os 0.3.807 → 0.3.815
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 +3 -2
- package/app/api/reach/probe/route.test.ts +22 -0
- package/app/api/reach/probe/route.ts +12 -0
- package/app/api/reach/route.ts +30 -0
- package/app/api/sessions/list-size.test.ts +77 -0
- package/app/api/sessions/route.ts +21 -10
- package/app/api/tasks/list-size.test.ts +50 -0
- package/app/api/tasks/route.ts +20 -3
- package/components/Chat/ViewSwitch.tsx +2 -1
- package/components/Devices/NetworkSection.tsx +2 -0
- package/components/Media/MediaViewer.tsx +111 -0
- package/components/Media/viewer.test.ts +26 -0
- package/components/Media/viewer.ts +23 -0
- package/components/Pane/index.tsx +4 -2
- package/components/Reach/ReachRow.tsx +137 -0
- package/components/Settings/SetupSection.tsx +2 -0
- package/components/Tips/TipDialog.tsx +1 -1
- package/components/Tips/TipMedia.tsx +38 -3
- package/components/Updates/{UpdateBanner.test.ts → UpdateToast.test.ts} +158 -8
- package/components/Updates/{UpdateBanner.tsx → UpdateToast.tsx} +119 -89
- package/components/Updates/index.ts +1 -1
- package/components/Welcome/ConnectBlock.tsx +2 -0
- package/components/views/DesktopView.tsx +2 -2
- package/components/views/MobileView.tsx +2 -2
- package/content/tips.json +10 -0
- package/content/whats-new.json +48 -0
- package/data/devices/queries.ts +6 -1
- package/data/reach/index.ts +33 -0
- package/lib/connect/serve.ts +22 -3
- package/lib/connect/supervise.ts +33 -9
- package/lib/db/queries.ts +31 -4
- package/lib/db/types.ts +3 -1
- package/lib/guides.ts +6 -0
- package/lib/hosts/remote-menu.ts +4 -2
- package/lib/reach/check-cli.ts +117 -0
- package/lib/reach/checkReach.ts +62 -0
- package/lib/reach/classify.ts +283 -0
- package/lib/reach/cli.ts +38 -0
- package/lib/reach/gatherFacts.ts +167 -0
- package/lib/reach/index.ts +4 -0
- package/lib/reach/probeConnect.ts +122 -0
- package/lib/reach/types.ts +96 -0
- package/lib/security/auth.ts +2 -0
- package/lib/security/demo.ts +2 -0
- package/lib/sessions/has-prompt.ts +7 -0
- package/lib/sessions/omit-null.ts +8 -0
- package/lib/tasks/contract/views.ts +12 -0
- package/lib/tasks/index.ts +9 -5
- package/lib/tasks/session.ts +15 -1
- package/lib/tips/view.ts +2 -4
- package/package.json +1 -1
- package/scripts/agent-os +3 -0
- package/scripts/install.sh +12 -1
- package/scripts/lib/commands.sh +59 -65
- package/scripts/lib/service.sh +230 -0
- package/scripts/lib/setup.sh +303 -0
- package/scripts/lib/setup.test.ts +389 -0
package/README.md
CHANGED
|
@@ -55,16 +55,17 @@ You need Node.js 22+, git, tmux, and at least one agent CLI (the installer offer
|
|
|
55
55
|
```bash
|
|
56
56
|
npm install -g @saadnvd1/agent-os
|
|
57
57
|
agent-os install # checks for tmux and ripgrep, installs what's missing, builds the app
|
|
58
|
-
agent-os
|
|
58
|
+
agent-os setup # keeps it running, chooses how your phone reaches it, adds the phone
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
Without Node.js, the install script sets up everything:
|
|
62
62
|
|
|
63
63
|
```bash
|
|
64
64
|
curl -fsSL https://raw.githubusercontent.com/saadnvd1/agent-os/main/scripts/install.sh | bash
|
|
65
|
-
agent-os start
|
|
66
65
|
```
|
|
67
66
|
|
|
67
|
+
It ends by offering `agent-os setup`. If another device can't reach the machine later, `agent-os check` names the cause and the fix ([guide](docs/guides/reachability.md)).
|
|
68
|
+
|
|
68
69
|
Then open AgentOS, add a project from ⌘K, and press ⌥N. The [install guide](docs/guides/install.md) covers Windows (WSL2), the supported agents, first run and the `agent-os` commands. To use it from your phone, see the [phone guide](docs/guides/phone.md).
|
|
69
70
|
|
|
70
71
|
## Connect
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { GET } from "./route";
|
|
3
|
+
|
|
4
|
+
// Public before pairing: it may only ever say "AgentOS, this boot".
|
|
5
|
+
describe("GET /api/reach/probe", () => {
|
|
6
|
+
it("answers the same two fields to everyone, all boot long, uncached", async () => {
|
|
7
|
+
const a = GET();
|
|
8
|
+
const b = GET();
|
|
9
|
+
const body = await a.json();
|
|
10
|
+
expect(Object.keys(body).sort()).toEqual(["boot", "ok"]);
|
|
11
|
+
expect(body).toEqual({
|
|
12
|
+
ok: true,
|
|
13
|
+
boot: expect.stringMatching(/^[0-9a-f]{16}$/),
|
|
14
|
+
});
|
|
15
|
+
expect(await b.json()).toEqual(body);
|
|
16
|
+
expect(a.headers.get("cache-control")).toBe("no-store");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("takes no input", () => {
|
|
20
|
+
expect(GET.length).toBe(0);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { bootId } from "@/lib/reach/probeConnect";
|
|
3
|
+
|
|
4
|
+
// GET /api/reach/probe - public: the same answer for anyone, all boot long.
|
|
5
|
+
// This machine fetches it through its own Connect address to prove the
|
|
6
|
+
// answer came from this process, not a cache or another AgentOS.
|
|
7
|
+
export function GET() {
|
|
8
|
+
return NextResponse.json(
|
|
9
|
+
{ ok: true, boot: bootId() },
|
|
10
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { checkReach } from "@/lib/reach/checkReach";
|
|
3
|
+
import { classifyReach } from "@/lib/reach/classify";
|
|
4
|
+
import { demoMode } from "@/lib/security/demo";
|
|
5
|
+
import type { ReachRoute } from "@/lib/reach/types";
|
|
6
|
+
|
|
7
|
+
const ROUTES = new Set<string>(["connect", "tailscale", "lan"]);
|
|
8
|
+
|
|
9
|
+
// GET /api/reach[?fresh][&want=lan,connect] - can other devices reach this
|
|
10
|
+
// machine, and if not, why. `want` adds routes the caller means to use.
|
|
11
|
+
export async function GET(request: NextRequest) {
|
|
12
|
+
if (demoMode()) return NextResponse.json(demoReach());
|
|
13
|
+
const params = request.nextUrl.searchParams;
|
|
14
|
+
const want = (params.get("want") ?? "")
|
|
15
|
+
.split(",")
|
|
16
|
+
.filter((r): r is ReachRoute => ROUTES.has(r));
|
|
17
|
+
return NextResponse.json(await checkReach(params.has("fresh"), want));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Nothing about the machine a demo runs on.
|
|
21
|
+
function demoReach() {
|
|
22
|
+
return classifyReach({
|
|
23
|
+
now: Date.now(),
|
|
24
|
+
port: 3011,
|
|
25
|
+
lan: { on: false, addresses: [] },
|
|
26
|
+
firewall: "unknown",
|
|
27
|
+
tailscale: { state: "missing" },
|
|
28
|
+
connect: { state: "off", linked: false, enabled: false },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
const { db, insertSessionRow } = await import("@/lib/db");
|
|
5
|
+
const { GET } = await import("./route");
|
|
6
|
+
|
|
7
|
+
type Row = Record<string, unknown> & { id: string };
|
|
8
|
+
|
|
9
|
+
function seed(extra: Record<string, unknown> = {}): string {
|
|
10
|
+
const id = randomUUID();
|
|
11
|
+
insertSessionRow(db, {
|
|
12
|
+
id,
|
|
13
|
+
name: "n",
|
|
14
|
+
tmux_name: `claude-${id}`,
|
|
15
|
+
working_directory: "/tmp",
|
|
16
|
+
...extra,
|
|
17
|
+
});
|
|
18
|
+
return id;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const list = async (query = "") => {
|
|
22
|
+
const res = await GET({
|
|
23
|
+
nextUrl: new URL(`http://x/api/sessions${query}`),
|
|
24
|
+
} as never);
|
|
25
|
+
return (await res.json()).sessions as Row[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
db.prepare(`DELETE FROM sessions`).run();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("GET /api/sessions payload", () => {
|
|
33
|
+
it("never carries the full prompt, the chat context or the orchestrator token", async () => {
|
|
34
|
+
const id = seed({
|
|
35
|
+
task_status: "running",
|
|
36
|
+
task_prompt: "x".repeat(5000),
|
|
37
|
+
chat_context: "ctx",
|
|
38
|
+
orch_token: "secret-token",
|
|
39
|
+
});
|
|
40
|
+
const row = (await list()).find((r) => r.id === id)!;
|
|
41
|
+
// Installed phone builds read !!task_prompt, so a short prefix stays.
|
|
42
|
+
expect(row.task_prompt).toBe("x".repeat(80));
|
|
43
|
+
expect(row).not.toHaveProperty("chat_context");
|
|
44
|
+
expect(row).not.toHaveProperty("orch_token");
|
|
45
|
+
expect(row.has_prompt).toBe(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("says has_prompt 0 for a plain session", async () => {
|
|
49
|
+
const id = seed();
|
|
50
|
+
expect((await list()).find((r) => r.id === id)!.has_prompt).toBe(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("leaves null fields out", async () => {
|
|
54
|
+
const id = seed();
|
|
55
|
+
const row = (await list()).find((r) => r.id === id)!;
|
|
56
|
+
expect(Object.values(row)).not.toContain(null);
|
|
57
|
+
expect(row).not.toHaveProperty("pr_url");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("lists the 100 most recent merged tasks, all with ?done=all", async () => {
|
|
61
|
+
for (let i = 0; i < 103; i++) {
|
|
62
|
+
const day = String(i + 1).padStart(3, "0");
|
|
63
|
+
seed({
|
|
64
|
+
task_status: "merged",
|
|
65
|
+
merged_at: `2026-01-01 00:00:${i % 60}`.replace(/:(\d)$/, ":0$1"),
|
|
66
|
+
updated_at: `2026-02-01 00:00:00`,
|
|
67
|
+
name: `done-${day}`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const live = seed({ task_status: "running" });
|
|
71
|
+
const capped = await list();
|
|
72
|
+
expect(capped.filter((r) => r.task_status === "merged")).toHaveLength(100);
|
|
73
|
+
expect(capped.map((r) => r.id)).toContain(live);
|
|
74
|
+
const all = await list("?done=all");
|
|
75
|
+
expect(all.filter((r) => r.task_status === "merged")).toHaveLength(103);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -10,9 +10,14 @@ import { ModelUnavailableError } from "@/lib/models/agent-binding";
|
|
|
10
10
|
import { namesLine, waitBadges } from "@/lib/bus/waits/store";
|
|
11
11
|
import { claimsSnapshot } from "@/lib/claims";
|
|
12
12
|
import { isProjectRef } from "@/lib/tasks/project-ref";
|
|
13
|
+
import { omitNulls } from "@/lib/sessions/omit-null";
|
|
14
|
+
|
|
15
|
+
// The sidebar's Done shelf lists this many finished tasks; ?done=all lists
|
|
16
|
+
// every one.
|
|
17
|
+
const DONE_LISTED = 100;
|
|
13
18
|
|
|
14
19
|
// GET /api/sessions - List the sidebar's sessions and the user's groups
|
|
15
|
-
export async function GET() {
|
|
20
|
+
export async function GET(request?: NextRequest) {
|
|
16
21
|
try {
|
|
17
22
|
const db = getDb();
|
|
18
23
|
// Merged tasks are listed (the sidebar files them under Done). Dropped
|
|
@@ -20,12 +25,18 @@ export async function GET() {
|
|
|
20
25
|
// and archived sessions in the Archived view: none is in the sidebar.
|
|
21
26
|
const badges = waitBadges();
|
|
22
27
|
const claims = claimsSnapshot();
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
const all = request?.nextUrl.searchParams.get("done") === "all";
|
|
29
|
+
const rows = (
|
|
30
|
+
all
|
|
31
|
+
? queries.getSidebarSessions(db).all()
|
|
32
|
+
: queries.getSidebarSessionsCapped(db, DONE_LISTED).all()
|
|
33
|
+
) as Session[];
|
|
34
|
+
const sessions = rows.map((s) => {
|
|
35
|
+
const b = badges.get(s.id);
|
|
36
|
+
const overlap = claims.bySession.get(s.id);
|
|
37
|
+
const row = overlap ? { ...s, overlap } : s;
|
|
38
|
+
return omitNulls(
|
|
39
|
+
b
|
|
29
40
|
? {
|
|
30
41
|
...row,
|
|
31
42
|
waiting_on: b.on.length
|
|
@@ -35,9 +46,9 @@ export async function GET() {
|
|
|
35
46
|
? namesLine(b.by.map((w) => w.name))
|
|
36
47
|
: null,
|
|
37
48
|
}
|
|
38
|
-
: row
|
|
39
|
-
|
|
40
|
-
);
|
|
49
|
+
: row
|
|
50
|
+
);
|
|
51
|
+
});
|
|
41
52
|
return NextResponse.json({
|
|
42
53
|
sessions,
|
|
43
54
|
sessionGroups: listGroups(),
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
const { db, insertSessionRow } = await import("@/lib/db");
|
|
5
|
+
const { GET } = await import("./route");
|
|
6
|
+
|
|
7
|
+
describe("GET /api/tasks payload", () => {
|
|
8
|
+
it("lists hasPrompt, not the prompt text, and leaves null fields out", async () => {
|
|
9
|
+
const id = randomUUID();
|
|
10
|
+
insertSessionRow(db, {
|
|
11
|
+
id,
|
|
12
|
+
name: "t",
|
|
13
|
+
tmux_name: `claude-${id}`,
|
|
14
|
+
working_directory: "/tmp",
|
|
15
|
+
task_status: "running",
|
|
16
|
+
task_prompt: "p".repeat(5000),
|
|
17
|
+
});
|
|
18
|
+
const { tasks } = await (await GET()).json();
|
|
19
|
+
const t = tasks.find((x: { id: string }) => x.id === id);
|
|
20
|
+
expect(t.hasPrompt).toBe(true);
|
|
21
|
+
expect(t).not.toHaveProperty("prompt");
|
|
22
|
+
expect(JSON.stringify(t)).not.toContain("ppppp");
|
|
23
|
+
expect(Object.values(t)).not.toContain(null);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("taskSessions with a merged limit", () => {
|
|
28
|
+
it("keeps the most recently merged ones and every unmerged one", async () => {
|
|
29
|
+
const { taskSessions } = await import("@/lib/tasks/session");
|
|
30
|
+
const add = (status: string, merged_at: string | null) => {
|
|
31
|
+
const id = randomUUID();
|
|
32
|
+
insertSessionRow(db, {
|
|
33
|
+
id,
|
|
34
|
+
name: "t",
|
|
35
|
+
tmux_name: `claude-${id}`,
|
|
36
|
+
working_directory: "/tmp",
|
|
37
|
+
task_status: status,
|
|
38
|
+
merged_at,
|
|
39
|
+
});
|
|
40
|
+
return id;
|
|
41
|
+
};
|
|
42
|
+
const oldest = add("merged", "2020-01-01 00:00:00");
|
|
43
|
+
const newer = add("merged", "2020-01-03 00:00:00");
|
|
44
|
+
const newest = add("merged", "2020-01-04 00:00:00");
|
|
45
|
+
const running = add("running", null);
|
|
46
|
+
const ids = taskSessions(false, 2).map((s) => s.id);
|
|
47
|
+
expect(ids).toEqual(expect.arrayContaining([newer, newest, running]));
|
|
48
|
+
expect(ids).not.toContain(oldest);
|
|
49
|
+
});
|
|
50
|
+
});
|
package/app/api/tasks/route.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parseOverride } from "@/lib/models/resolve";
|
|
2
2
|
import { NextRequest, NextResponse } from "next/server";
|
|
3
|
-
import { createTask, listTasks, movedTasks } from "@/lib/tasks";
|
|
3
|
+
import { createTask, listTasks, movedTasks, type TaskView } from "@/lib/tasks";
|
|
4
4
|
import { stampPriority, startOrQueue } from "@/lib/tasks/queue";
|
|
5
5
|
import { isPriority } from "@/lib/tasks/schedule-types";
|
|
6
6
|
import { TASK_CAPABILITIES } from "@/lib/tasks/contract/types";
|
|
@@ -13,12 +13,29 @@ import {
|
|
|
13
13
|
} from "@/lib/tasks/move-bundle";
|
|
14
14
|
import { startView } from "@/lib/sessions/launch";
|
|
15
15
|
import { isValidAgentType } from "@/lib/providers";
|
|
16
|
+
import { omitNulls } from "@/lib/sessions/omit-null";
|
|
16
17
|
import { isAgentModelName } from "@/lib/providers/registry";
|
|
17
18
|
|
|
19
|
+
// The most recent merged tasks the list holds; ?done=all lists every one.
|
|
20
|
+
const MERGED_LISTED = 100;
|
|
21
|
+
|
|
22
|
+
// A task as the list shows it: no prompt text (a mirrored one from a machine
|
|
23
|
+
// that still sends it included) and no null fields.
|
|
24
|
+
function listed(task: TaskView) {
|
|
25
|
+
const { prompt: _prompt, ...rest } = task;
|
|
26
|
+
return omitNulls(rest);
|
|
27
|
+
}
|
|
28
|
+
|
|
18
29
|
// moved and capabilities are for another machine that mirrors these tasks.
|
|
19
|
-
export async function GET() {
|
|
30
|
+
export async function GET(request?: NextRequest) {
|
|
20
31
|
return NextResponse.json({
|
|
21
|
-
tasks:
|
|
32
|
+
tasks: (
|
|
33
|
+
await listTasks(
|
|
34
|
+
request?.nextUrl.searchParams.get("done") === "all"
|
|
35
|
+
? undefined
|
|
36
|
+
: MERGED_LISTED
|
|
37
|
+
)
|
|
38
|
+
).map(listed),
|
|
22
39
|
moved: movedTasks(),
|
|
23
40
|
capabilities: TASK_CAPABILITIES,
|
|
24
41
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { MessageSquare, SquareTerminal } from "lucide-react";
|
|
4
|
+
import { hasPrompt } from "@/lib/sessions/has-prompt";
|
|
4
5
|
import type { Session } from "@/lib/db";
|
|
5
6
|
import { supportsChat } from "@/lib/chat/capabilities";
|
|
6
7
|
import { viewSwitchActions } from "@/stores/viewSwitch";
|
|
@@ -15,7 +16,7 @@ export function ViewSwitch({
|
|
|
15
16
|
if (!session || !supportsChat(session.agent_type)) return null;
|
|
16
17
|
if (session.host_id && session.host_id !== "local") return null;
|
|
17
18
|
// A task's agent runs in its terminal with its brief; chat would end it.
|
|
18
|
-
if (session
|
|
19
|
+
if (hasPrompt(session)) return null;
|
|
19
20
|
const options = [
|
|
20
21
|
{ view: "chat" as const, icon: MessageSquare, label: "Chat" },
|
|
21
22
|
{ view: "terminal" as const, icon: SquareTerminal, label: "Terminal" },
|
|
@@ -4,6 +4,7 @@ import { SettingToggle as Toggle } from "@/components/Settings/SettingToggle";
|
|
|
4
4
|
import { useNetworkQuery, useUpdateNetwork } from "@/data/devices";
|
|
5
5
|
import { TailscaleCard } from "./TailscaleCard";
|
|
6
6
|
import { ConnectCard } from "./ConnectCard";
|
|
7
|
+
import { ReachRow } from "@/components/Reach/ReachRow";
|
|
7
8
|
|
|
8
9
|
export function NetworkSection({
|
|
9
10
|
open,
|
|
@@ -22,6 +23,7 @@ export function NetworkSection({
|
|
|
22
23
|
|
|
23
24
|
return (
|
|
24
25
|
<div className="space-y-2">
|
|
26
|
+
<ReachRow enabled={open} />
|
|
25
27
|
<ConnectCard
|
|
26
28
|
connect={data.connect}
|
|
27
29
|
canManage={canManage}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
4
|
+
import { X } from "lucide-react";
|
|
5
|
+
import { useRef, useState, type TouchEvent } from "react";
|
|
6
|
+
import { Dialog, DialogOverlay, DialogPortal } from "@/components/ui/dialog";
|
|
7
|
+
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
|
8
|
+
import { isCloseSwipe, type ViewerMedia } from "./viewer";
|
|
9
|
+
|
|
10
|
+
/** Full-screen image or clip over everything; closes on its own, never its parent dialog. */
|
|
11
|
+
export function MediaViewer({
|
|
12
|
+
media,
|
|
13
|
+
open,
|
|
14
|
+
onOpenChange,
|
|
15
|
+
}: {
|
|
16
|
+
media: ViewerMedia;
|
|
17
|
+
open: boolean;
|
|
18
|
+
onOpenChange: (open: boolean) => void;
|
|
19
|
+
}) {
|
|
20
|
+
const reduced = useMediaQuery("(prefers-reduced-motion: reduce)");
|
|
21
|
+
const start = useRef<{ x: number; y: number } | null>(null);
|
|
22
|
+
const [drag, setDrag] = useState(0);
|
|
23
|
+
|
|
24
|
+
const onTouchStart = (e: TouchEvent) => {
|
|
25
|
+
const t = e.touches[0];
|
|
26
|
+
start.current = { x: t.clientX, y: t.clientY };
|
|
27
|
+
};
|
|
28
|
+
const onTouchMove = (e: TouchEvent) => {
|
|
29
|
+
if (!start.current) return;
|
|
30
|
+
setDrag(Math.max(0, e.touches[0].clientY - start.current.y));
|
|
31
|
+
};
|
|
32
|
+
const onTouchEnd = (e: TouchEvent) => {
|
|
33
|
+
const s = start.current;
|
|
34
|
+
start.current = null;
|
|
35
|
+
setDrag(0);
|
|
36
|
+
if (!s) return;
|
|
37
|
+
const t = e.changedTouches[0];
|
|
38
|
+
if (isCloseSwipe(t.clientX - s.x, t.clientY - s.y)) onOpenChange(false);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
43
|
+
<DialogPortal>
|
|
44
|
+
<DialogOverlay className="z-[70] bg-black/90" />
|
|
45
|
+
<DialogPrimitive.Content
|
|
46
|
+
aria-label={media.alt}
|
|
47
|
+
aria-describedby={undefined}
|
|
48
|
+
onClick={(e) => {
|
|
49
|
+
// The backdrop is this full-screen box; the media itself isn't a target.
|
|
50
|
+
if (e.target === e.currentTarget) onOpenChange(false);
|
|
51
|
+
}}
|
|
52
|
+
onTouchStart={onTouchStart}
|
|
53
|
+
onTouchMove={onTouchMove}
|
|
54
|
+
onTouchEnd={onTouchEnd}
|
|
55
|
+
onTouchCancel={() => {
|
|
56
|
+
start.current = null;
|
|
57
|
+
setDrag(0);
|
|
58
|
+
}}
|
|
59
|
+
style={{
|
|
60
|
+
transform: drag ? `translateY(${drag}px)` : undefined,
|
|
61
|
+
paddingTop: "max(1rem, env(safe-area-inset-top))",
|
|
62
|
+
paddingRight: "max(1rem, env(safe-area-inset-right))",
|
|
63
|
+
paddingBottom: "max(1rem, env(safe-area-inset-bottom))",
|
|
64
|
+
paddingLeft: "max(1rem, env(safe-area-inset-left))",
|
|
65
|
+
}}
|
|
66
|
+
className="fixed inset-0 z-[70] grid place-items-center outline-none"
|
|
67
|
+
>
|
|
68
|
+
<DialogPrimitive.Title className="sr-only">
|
|
69
|
+
{media.alt}
|
|
70
|
+
</DialogPrimitive.Title>
|
|
71
|
+
{media.kind === "image" ? (
|
|
72
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
73
|
+
<img
|
|
74
|
+
src={media.src}
|
|
75
|
+
alt={media.alt}
|
|
76
|
+
width={media.width}
|
|
77
|
+
height={media.height}
|
|
78
|
+
decoding="async"
|
|
79
|
+
className="max-h-full max-w-full rounded-lg object-contain"
|
|
80
|
+
/>
|
|
81
|
+
) : (
|
|
82
|
+
<video
|
|
83
|
+
key={media.src}
|
|
84
|
+
src={media.src}
|
|
85
|
+
poster={media.poster}
|
|
86
|
+
aria-label={media.alt}
|
|
87
|
+
width={media.width}
|
|
88
|
+
height={media.height}
|
|
89
|
+
muted
|
|
90
|
+
loop
|
|
91
|
+
playsInline
|
|
92
|
+
autoPlay={!reduced}
|
|
93
|
+
controls={reduced}
|
|
94
|
+
className="max-h-full max-w-full rounded-lg object-contain"
|
|
95
|
+
/>
|
|
96
|
+
)}
|
|
97
|
+
<DialogPrimitive.Close
|
|
98
|
+
aria-label="Close"
|
|
99
|
+
className="absolute grid size-11 place-items-center rounded-full bg-black/60 text-white hover:bg-black/80 focus-visible:ring-2 focus-visible:ring-white focus-visible:outline-none"
|
|
100
|
+
style={{
|
|
101
|
+
top: "max(0.75rem, env(safe-area-inset-top))",
|
|
102
|
+
right: "max(0.75rem, env(safe-area-inset-right))",
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
<X className="size-5" />
|
|
106
|
+
</DialogPrimitive.Close>
|
|
107
|
+
</DialogPrimitive.Content>
|
|
108
|
+
</DialogPortal>
|
|
109
|
+
</Dialog>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isCloseSwipe, pickSrc } from "./viewer";
|
|
3
|
+
|
|
4
|
+
describe("pickSrc", () => {
|
|
5
|
+
const media = { src: "/a.png", srcDark: "/a-dark.png" };
|
|
6
|
+
it("uses the dark image in a dark theme", () => {
|
|
7
|
+
expect(pickSrc(media, true)).toBe("/a-dark.png");
|
|
8
|
+
});
|
|
9
|
+
it("uses the light image otherwise", () => {
|
|
10
|
+
expect(pickSrc(media, false)).toBe("/a.png");
|
|
11
|
+
});
|
|
12
|
+
it("falls back when there is no dark variant", () => {
|
|
13
|
+
expect(pickSrc({ src: "/a.png" }, true)).toBe("/a.png");
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("isCloseSwipe", () => {
|
|
18
|
+
it("closes on a long downward drag", () => {
|
|
19
|
+
expect(isCloseSwipe(5, 120)).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
it("ignores short drags, upward drags and sideways drags", () => {
|
|
22
|
+
expect(isCloseSwipe(0, 30)).toBe(false);
|
|
23
|
+
expect(isCloseSwipe(0, -200)).toBe(false);
|
|
24
|
+
expect(isCloseSwipe(150, 90)).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface ViewerMedia {
|
|
2
|
+
kind: "image" | "video";
|
|
3
|
+
src: string;
|
|
4
|
+
alt: string;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
poster?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The dark variant when the theme is dark and one exists. */
|
|
11
|
+
export function pickSrc(
|
|
12
|
+
media: { src: string; srcDark?: string },
|
|
13
|
+
dark: boolean
|
|
14
|
+
): string {
|
|
15
|
+
return dark && media.srcDark ? media.srcDark : media.src;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const SWIPE_CLOSE_PX = 80;
|
|
19
|
+
|
|
20
|
+
/** A mostly-vertical downward drag past the threshold closes the viewer. */
|
|
21
|
+
export function isCloseSwipe(dx: number, dy: number): boolean {
|
|
22
|
+
return dy >= SWIPE_CLOSE_PX && dy > Math.abs(dx) * 1.2;
|
|
23
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { hasPrompt } from "@/lib/sessions/has-prompt";
|
|
3
4
|
import { useTaskLogQuery } from "@/data/session-logs";
|
|
4
5
|
import { SetupCard } from "@/components/Chat/SetupCard";
|
|
5
6
|
import { SignInCard } from "@/components/Terminal/SignInCard";
|
|
@@ -217,10 +218,11 @@ export const Pane = memo(function Pane({
|
|
|
217
218
|
|
|
218
219
|
// A merged task keeps the log AgentOS wrote of it.
|
|
219
220
|
const merged =
|
|
220
|
-
!!session
|
|
221
|
+
!!session &&
|
|
222
|
+
hasPrompt(session) &&
|
|
221
223
|
(session.task_status === "merged" || session.pr_status === "merged");
|
|
222
224
|
const hasLog = !!useTaskLogQuery(session?.id, merged).data;
|
|
223
|
-
const hasReport = !!session
|
|
225
|
+
const hasReport = !!session && hasPrompt(session);
|
|
224
226
|
|
|
225
227
|
// Get current project and its repositories
|
|
226
228
|
const currentProject = useMemo(() => {
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Loader2, RefreshCw } from "lucide-react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { StatusPill } from "@/components/ui/status-pill";
|
|
6
|
+
import { CopyButton } from "@/components/Chat/CopyButton";
|
|
7
|
+
import { useReachQuery, useRecheckReach } from "@/data/reach";
|
|
8
|
+
import type { ReachFinding, ReachReport, ReachStatus } from "@/lib/reach/types";
|
|
9
|
+
import { cn } from "@/lib/utils";
|
|
10
|
+
|
|
11
|
+
const TONE: Record<ReachStatus, "green" | "amber" | "red"> = {
|
|
12
|
+
ok: "green",
|
|
13
|
+
warn: "amber",
|
|
14
|
+
fail: "red",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Amber has three stories: only retrying, one way in working, or a warning.
|
|
18
|
+
function pillLabel(report: ReachReport, problems: ReachFinding[]): string {
|
|
19
|
+
if (report.status === "ok") return "Reachable";
|
|
20
|
+
if (report.status === "fail") return "Not reachable";
|
|
21
|
+
if (problems.every((f) => f.needs === "nothing")) return "Reconnecting";
|
|
22
|
+
return report.url ? "Partly reachable" : "Needs a look";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A fix is a command when it starts with one; otherwise it's plain advice.
|
|
26
|
+
const isCommand = (fix: string) => /^(agent-os|sudo)( |$)/.test(fix);
|
|
27
|
+
|
|
28
|
+
function Problem({ f }: { f: ReachFinding }) {
|
|
29
|
+
return (
|
|
30
|
+
<li className="flex flex-col gap-1">
|
|
31
|
+
<p className="text-sm font-medium">{f.title}</p>
|
|
32
|
+
{f.needs === "nothing" ? (
|
|
33
|
+
<p className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
|
34
|
+
<Loader2 className="size-3 animate-spin" />
|
|
35
|
+
Retrying on its own, nothing to do
|
|
36
|
+
</p>
|
|
37
|
+
) : (
|
|
38
|
+
<>
|
|
39
|
+
{f.fix &&
|
|
40
|
+
(isCommand(f.fix) ? (
|
|
41
|
+
<code className="bg-background block rounded px-2 py-1 text-xs break-all">
|
|
42
|
+
{f.fix}
|
|
43
|
+
</code>
|
|
44
|
+
) : (
|
|
45
|
+
<p className="text-muted-foreground text-xs break-words">
|
|
46
|
+
{f.fix}
|
|
47
|
+
</p>
|
|
48
|
+
))}
|
|
49
|
+
{f.guide && (
|
|
50
|
+
<a
|
|
51
|
+
href={f.guide}
|
|
52
|
+
target="_blank"
|
|
53
|
+
rel="noopener noreferrer"
|
|
54
|
+
className="text-muted-foreground hover:text-foreground flex min-h-11 w-fit items-center text-xs underline underline-offset-2 sm:min-h-6"
|
|
55
|
+
>
|
|
56
|
+
How to fix
|
|
57
|
+
</a>
|
|
58
|
+
)}
|
|
59
|
+
</>
|
|
60
|
+
)}
|
|
61
|
+
</li>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Whether other devices can reach this machine, and the fix when they can't. */
|
|
66
|
+
export function ReachRow({
|
|
67
|
+
enabled = true,
|
|
68
|
+
className,
|
|
69
|
+
}: {
|
|
70
|
+
enabled?: boolean;
|
|
71
|
+
className?: string;
|
|
72
|
+
}) {
|
|
73
|
+
const { data, isPending, isError } = useReachQuery(enabled);
|
|
74
|
+
const recheck = useRecheckReach();
|
|
75
|
+
if (isPending)
|
|
76
|
+
return (
|
|
77
|
+
<div
|
|
78
|
+
className={cn("bg-muted/40 h-16 animate-pulse rounded-lg", className)}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
const problems = data?.findings.filter((f) => f.status !== "ok") ?? [];
|
|
82
|
+
const pill = data && {
|
|
83
|
+
tone: TONE[data.status],
|
|
84
|
+
label: pillLabel(data, problems),
|
|
85
|
+
};
|
|
86
|
+
return (
|
|
87
|
+
<div
|
|
88
|
+
className={cn(
|
|
89
|
+
"bg-muted/40 flex flex-col gap-2 rounded-lg px-3 py-2.5",
|
|
90
|
+
className
|
|
91
|
+
)}
|
|
92
|
+
>
|
|
93
|
+
<div className="flex min-h-11 items-center gap-2 sm:min-h-0">
|
|
94
|
+
<p className="min-w-0 flex-1 text-sm font-medium">
|
|
95
|
+
Reachable from other devices
|
|
96
|
+
</p>
|
|
97
|
+
{pill && (
|
|
98
|
+
<StatusPill tone={pill.tone} className="shrink-0 whitespace-nowrap">
|
|
99
|
+
{pill.label}
|
|
100
|
+
</StatusPill>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
{isError && (
|
|
104
|
+
<p className="text-destructive text-xs">Couldn't run the check.</p>
|
|
105
|
+
)}
|
|
106
|
+
{data?.url && (
|
|
107
|
+
<div className="flex min-w-0 items-center gap-1">
|
|
108
|
+
<span className="text-foreground min-w-0 flex-1 truncate font-mono text-xs">
|
|
109
|
+
{data.url}
|
|
110
|
+
</span>
|
|
111
|
+
<CopyButton
|
|
112
|
+
text={data.url}
|
|
113
|
+
label="Copy link"
|
|
114
|
+
className="h-11 w-11 shrink-0 md:h-7 md:w-7"
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
{problems.length > 0 && (
|
|
119
|
+
<ul className="flex flex-col gap-3">
|
|
120
|
+
{problems.map((f, i) => (
|
|
121
|
+
<Problem key={`${f.route ?? ""}-${f.cause ?? i}`} f={f} />
|
|
122
|
+
))}
|
|
123
|
+
</ul>
|
|
124
|
+
)}
|
|
125
|
+
<Button
|
|
126
|
+
size="sm"
|
|
127
|
+
variant="ghost"
|
|
128
|
+
className="h-11 self-end sm:h-8"
|
|
129
|
+
disabled={recheck.isPending}
|
|
130
|
+
onClick={() => recheck.mutate()}
|
|
131
|
+
>
|
|
132
|
+
<RefreshCw className={recheck.isPending ? "animate-spin" : ""} />
|
|
133
|
+
Check again
|
|
134
|
+
</Button>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|