@saadnvd1/agent-os 0.3.594 → 0.3.596

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 (47) hide show
  1. package/README.md +1 -0
  2. package/app/api/sessions/[id]/artifacts/[artifactId]/route.ts +34 -0
  3. package/app/api/sessions/[id]/artifacts/route.ts +25 -9
  4. package/components/Artifacts/ArtifactPreview.tsx +239 -0
  5. package/components/Artifacts/ArtifactRow.tsx +147 -0
  6. package/components/Artifacts/ArtifactsPanel.tsx +244 -0
  7. package/components/Artifacts/helpers.test.ts +90 -0
  8. package/components/Artifacts/helpers.ts +99 -0
  9. package/components/Artifacts/index.ts +1 -0
  10. package/components/Chat/ArtifactFrame.tsx +4 -1
  11. package/components/Pane/DesktopTabBar.tsx +2 -2
  12. package/components/Pane/MobileTabBar.tsx +14 -2
  13. package/components/Pane/PaneViewToggle.tsx +20 -3
  14. package/components/Pane/index.tsx +35 -3
  15. package/components/Providers.tsx +5 -1
  16. package/components/SessionList/LiveLine.tsx +1 -1
  17. package/components/SessionList/OrchestratorPinRow.tsx +10 -5
  18. package/components/SessionList/QuickChangeRow.tsx +1 -1
  19. package/components/SessionList/RowParts.tsx +37 -2
  20. package/components/SessionList/RowShell.tsx +6 -2
  21. package/components/SessionList/RowSubtitle.tsx +20 -13
  22. package/components/SessionList/SessionRow.tsx +28 -17
  23. package/components/SessionList/SessionShelves.tsx +40 -8
  24. package/components/SessionList/SidebarExtras.tsx +7 -1
  25. package/components/SessionList/index.tsx +2 -0
  26. package/components/SessionList/useSessionTips.ts +62 -0
  27. package/components/ui/HoverTip.tsx +20 -0
  28. package/contexts/PaneContext.tsx +7 -1
  29. package/data/presence/index.ts +14 -7
  30. package/data/session-artifacts/index.ts +2 -0
  31. package/data/session-artifacts/keys.ts +7 -0
  32. package/data/session-artifacts/queries.ts +53 -0
  33. package/lib/chat/task-output.ts +1 -1
  34. package/lib/done/worktree.ts +3 -0
  35. package/lib/hover-tip.ts +161 -0
  36. package/lib/security/demo.ts +1 -0
  37. package/lib/session-artifacts/access.ts +81 -0
  38. package/lib/session-artifacts/collect.ts +318 -0
  39. package/lib/session-artifacts/derive.ts +437 -0
  40. package/lib/session-artifacts/keep.ts +119 -0
  41. package/lib/session-artifacts/peer.ts +187 -0
  42. package/lib/session-artifacts/serve.ts +265 -0
  43. package/lib/session-artifacts/types.ts +43 -0
  44. package/lib/sidebar/row-badge.ts +40 -0
  45. package/lib/sidebar/row-details.ts +53 -0
  46. package/lib/tasks/finish.ts +2 -0
  47. package/package.json +1 -1
package/README.md CHANGED
@@ -21,6 +21,7 @@ and check on all of it from your phone.
21
21
  - **Agents that talk to each other.** Sessions message each other with `aos`, and you see every conversation in Messages. [More](docs/guides/agent-network.md)
22
22
  - **Chat or terminal, same conversation.** Streaming replies, steps and diffs, plan mode, slash commands, `@file`, undo. [More](docs/guides/chat.md)
23
23
  - **Bookmarks.** Star any message, tool call or turn, then find it again from the chat or across every chat, with a search. [More](docs/guides/chat.md)
24
+ - **Every artifact in one place.** A session's plan, PR, files, pages, images and job logs in one list, with a preview, kept after its worktree is removed. [More](docs/guides/sessions.md#artifacts)
24
25
  - **Share a chat or a page.** A read-only snapshot link with secrets, home paths and machine names masked, a preview before anything leaves your machine, and a Markdown copy to hand to an agent. [More](docs/guides/sharing.md)
25
26
  - **Project config.** `agentos.json` gives each session its own ports, environment, files and a private Postgres copy. [More](docs/reference/project-config.md)
26
27
  - **Schedules and recipes.** Start work on a timer, or save a job that comes up again. [More](docs/guides/schedules.md)
@@ -0,0 +1,34 @@
1
+ import { NextResponse } from "next/server";
2
+ import { peerLinkOf } from "@/lib/hosts/peer-actions";
3
+ import { collectArtifacts, getSession } from "@/lib/session-artifacts/collect";
4
+ import { peerContent } from "@/lib/session-artifacts/peer";
5
+ import { artifactContent } from "@/lib/session-artifacts/serve";
6
+
7
+ // GET /api/sessions/[id]/artifacts/[artifactId] - one artifact's content.
8
+ // By id only: a path the client sends is never read.
9
+ export async function GET(
10
+ _request: Request,
11
+ { params }: { params: Promise<{ id: string; artifactId: string }> }
12
+ ) {
13
+ const { id, artifactId } = await params;
14
+ const session = getSession(id);
15
+ if (!session)
16
+ return NextResponse.json({ error: "Session not found" }, { status: 404 });
17
+ const link = peerLinkOf(session);
18
+ const content = link
19
+ ? await peerContent(link, id, artifactId)
20
+ : await (async () => {
21
+ const entry = (await collectArtifacts(session)).find(
22
+ (e) => e.artifact.id === artifactId
23
+ );
24
+ return entry
25
+ ? artifactContent(session, entry)
26
+ : ({ ok: false, status: 404, error: "Not found" } as const);
27
+ })();
28
+ if (!content.ok)
29
+ return NextResponse.json(
30
+ { error: content.error },
31
+ { status: content.status }
32
+ );
33
+ return new NextResponse(content.body, { headers: content.headers });
34
+ }
@@ -1,17 +1,33 @@
1
1
  import { NextResponse } from "next/server";
2
- import { listArtifacts } from "@/lib/artifacts/store";
2
+ import { peerLinkOf } from "@/lib/hosts/peer-actions";
3
+ import { collectArtifacts, getSession } from "@/lib/session-artifacts/collect";
4
+ import { peerArtifacts } from "@/lib/session-artifacts/peer";
3
5
 
4
- // GET /api/sessions/[id]/artifacts - the pages this session's agent showed
6
+ // GET /api/sessions/[id]/artifacts - everything the session made: its plan,
7
+ // files, pages, images, job logs, PR, recipe run and log
5
8
  export async function GET(
6
9
  _request: Request,
7
10
  { params }: { params: Promise<{ id: string }> }
8
11
  ) {
9
12
  const { id } = await params;
10
- const artifacts = listArtifacts(id).map((a) => ({
11
- id: a.id,
12
- title: a.title,
13
- createdAt: a.created_at,
14
- url: `/api/artifacts/${a.id}`,
15
- }));
16
- return NextResponse.json({ artifacts });
13
+ const session = getSession(id);
14
+ if (!session)
15
+ return NextResponse.json({ error: "Session not found" }, { status: 404 });
16
+ const link = peerLinkOf(session);
17
+ if (link) {
18
+ try {
19
+ return NextResponse.json({
20
+ artifacts: await peerArtifacts(link, id),
21
+ host: link.hostName,
22
+ });
23
+ } catch {
24
+ return NextResponse.json({
25
+ artifacts: [],
26
+ host: link.hostName,
27
+ unreachable: true,
28
+ });
29
+ }
30
+ }
31
+ const entries = await collectArtifacts(session);
32
+ return NextResponse.json({ artifacts: entries.map((e) => e.artifact) });
17
33
  }
@@ -0,0 +1,239 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import {
5
+ Check,
6
+ ChevronLeft,
7
+ Copy,
8
+ ExternalLink,
9
+ FolderOpen,
10
+ } from "lucide-react";
11
+ import { Button } from "@/components/ui/button";
12
+ import { Markdown } from "@/components/Chat/Markdown";
13
+ import { ArtifactFrame } from "@/components/Chat/ArtifactFrame";
14
+ import { artifactContentUrl, useArtifactText } from "@/data/session-artifacts";
15
+ import { formatBytes } from "@/lib/share/view";
16
+ import { relativeTime } from "@/lib/schedules/when";
17
+ import { openable, type SessionArtifact } from "@/lib/session-artifacts/types";
18
+ import { cn } from "@/lib/utils";
19
+ import { ArtifactIcon } from "./ArtifactRow";
20
+ import { stateLabel } from "./helpers";
21
+
22
+ function Body({
23
+ sessionId,
24
+ artifact,
25
+ pr,
26
+ }: {
27
+ sessionId: string;
28
+ artifact: SessionArtifact;
29
+ pr: string | null;
30
+ }) {
31
+ const url = artifactContentUrl(sessionId, artifact.id);
32
+ const textual =
33
+ openable(artifact) &&
34
+ (artifact.preview === "markdown" || artifact.preview === "text");
35
+ const text = useArtifactText(textual ? url : null);
36
+
37
+ if (!openable(artifact))
38
+ return (
39
+ <p className="text-muted-foreground p-6 text-sm">
40
+ {artifact.state === "in-pr"
41
+ ? `Committed, and its worktree was removed: it's in ${pr ? `PR #${pr}` : "the PR"}.`
42
+ : artifact.state === "outside"
43
+ ? "Outside the folders AgentOS serves, so it isn't opened here."
44
+ : artifact.state === "gone"
45
+ ? "Gone: its worktree was removed and no copy was kept."
46
+ : "Nothing to preview."}
47
+ </p>
48
+ );
49
+
50
+ if (artifact.preview === "html")
51
+ return (
52
+ <ArtifactFrame
53
+ artifactId={artifact.id}
54
+ src={url}
55
+ title={artifact.name}
56
+ fill
57
+ className="bg-background"
58
+ />
59
+ );
60
+
61
+ if (artifact.preview === "image")
62
+ return (
63
+ <div className="flex min-h-full items-start justify-center p-4">
64
+ {/* eslint-disable-next-line @next/next/no-img-element */}
65
+ <img
66
+ src={url}
67
+ alt={artifact.name}
68
+ className="max-w-full rounded-lg border"
69
+ />
70
+ </div>
71
+ );
72
+
73
+ if (text.isPending)
74
+ return <div className="bg-muted/40 m-4 h-40 animate-pulse rounded-lg" />;
75
+ if (text.isError)
76
+ return <p className="text-destructive p-4 text-sm">{text.error.message}</p>;
77
+ const note = text.data.truncated && (
78
+ <p className="text-muted-foreground mb-3 text-xs">
79
+ {artifact.kind === "log"
80
+ ? "Showing the end of the output."
81
+ : "Showing the start of the file."}
82
+ </p>
83
+ );
84
+ if (artifact.preview === "markdown")
85
+ return (
86
+ <div className="mx-auto max-w-3xl px-5 py-5">
87
+ {note}
88
+ <Markdown text={text.data.text} />
89
+ </div>
90
+ );
91
+ return (
92
+ <div className="p-4">
93
+ {note}
94
+ <pre className="font-mono text-xs leading-relaxed break-words whitespace-pre-wrap">
95
+ {text.data.text}
96
+ </pre>
97
+ </div>
98
+ );
99
+ }
100
+
101
+ export function ArtifactPreview({
102
+ sessionId,
103
+ artifact,
104
+ pr,
105
+ mobile,
106
+ onBack,
107
+ onOpenInFiles,
108
+ }: {
109
+ sessionId: string;
110
+ artifact: SessionArtifact;
111
+ pr: string | null;
112
+ mobile: boolean;
113
+ onBack?: () => void;
114
+ onOpenInFiles?: (path: string) => void;
115
+ }) {
116
+ const [copied, setCopied] = useState(false);
117
+ const label = stateLabel(artifact, pr);
118
+ // Inside the worktree, the path from its top; the full one is copied.
119
+ const where =
120
+ artifact.path && artifact.folder !== undefined
121
+ ? [artifact.folder, artifact.path.split("/").pop()]
122
+ .filter(Boolean)
123
+ .join("/")
124
+ : artifact.path;
125
+ const meta = [
126
+ where,
127
+ label,
128
+ artifact.size !== undefined ? formatBytes(artifact.size) : null,
129
+ mobile && artifact.at ? relativeTime(artifact.at) : null,
130
+ ]
131
+ .filter(Boolean)
132
+ .join(" · ");
133
+ const copy = () => {
134
+ if (!artifact.path) return;
135
+ void navigator.clipboard?.writeText(artifact.path).then(() => {
136
+ setCopied(true);
137
+ setTimeout(() => setCopied(false), 1500);
138
+ });
139
+ };
140
+ const canOpenInFiles =
141
+ !!onOpenInFiles &&
142
+ !!artifact.path &&
143
+ artifact.state === "live" &&
144
+ (artifact.kind === "file" || artifact.kind === "plan");
145
+ const actions = (
146
+ <>
147
+ {artifact.url && (
148
+ <Button
149
+ variant="outline"
150
+ size="sm"
151
+ asChild
152
+ className={cn(mobile && "h-11 flex-1")}
153
+ >
154
+ <a
155
+ href={artifact.url}
156
+ target="_blank"
157
+ rel="noreferrer"
158
+ title="Open PR"
159
+ >
160
+ <ExternalLink className="h-3.5 w-3.5" />{" "}
161
+ <span className={cn(!mobile && "hidden @lg:inline")}>Open PR</span>
162
+ </a>
163
+ </Button>
164
+ )}
165
+ {artifact.path && (
166
+ <Button
167
+ variant="outline"
168
+ size="sm"
169
+ onClick={copy}
170
+ title="Copy path"
171
+ className={cn(mobile && "h-11 flex-1")}
172
+ >
173
+ {copied ? (
174
+ <Check className="h-3.5 w-3.5" />
175
+ ) : (
176
+ <Copy className="h-3.5 w-3.5" />
177
+ )}
178
+ <span className={cn(!mobile && "hidden @lg:inline")}>Copy path</span>
179
+ </Button>
180
+ )}
181
+ {canOpenInFiles && (
182
+ <Button
183
+ variant="outline"
184
+ size="sm"
185
+ onClick={() => onOpenInFiles(artifact.path!)}
186
+ title="Open in Files"
187
+ className={cn(mobile && "h-11 flex-1")}
188
+ >
189
+ <FolderOpen className="h-3.5 w-3.5" />{" "}
190
+ <span className={cn(!mobile && "hidden @lg:inline")}>
191
+ Open in Files
192
+ </span>
193
+ </Button>
194
+ )}
195
+ </>
196
+ );
197
+ const hasActions = !!(artifact.url || artifact.path);
198
+
199
+ return (
200
+ <div className="@container flex h-full min-w-0 flex-col">
201
+ <div className="flex items-center gap-2.5 border-b px-3 py-2.5 sm:px-4">
202
+ {mobile ? (
203
+ <Button
204
+ variant="ghost"
205
+ size="icon"
206
+ aria-label="Back to artifacts"
207
+ onClick={onBack}
208
+ className="-ml-1 h-11 w-11 shrink-0"
209
+ >
210
+ <ChevronLeft className="h-5 w-5" />
211
+ </Button>
212
+ ) : (
213
+ <ArtifactIcon artifact={artifact} />
214
+ )}
215
+ <div className="min-w-0 flex-1">
216
+ <div className="truncate text-[13px] font-medium">
217
+ {artifact.name}
218
+ </div>
219
+ {meta && (
220
+ <div className="text-muted-foreground truncate font-mono text-[11px]">
221
+ {meta}
222
+ </div>
223
+ )}
224
+ </div>
225
+ {!mobile && hasActions && (
226
+ <div className="flex shrink-0 gap-1.5">{actions}</div>
227
+ )}
228
+ </div>
229
+ <div className="min-h-0 flex-1 overflow-auto">
230
+ <Body sessionId={sessionId} artifact={artifact} pr={pr} />
231
+ </div>
232
+ {mobile && hasActions && (
233
+ <div className="flex gap-2 border-t px-3 pt-2.5 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
234
+ {actions}
235
+ </div>
236
+ )}
237
+ </div>
238
+ );
239
+ }
@@ -0,0 +1,147 @@
1
+ "use client";
2
+
3
+ import {
4
+ AppWindow,
5
+ FileCode2,
6
+ FileText,
7
+ GitPullRequest,
8
+ Image as ImageIcon,
9
+ ListChecks,
10
+ Play,
11
+ ScrollText,
12
+ SquareTerminal,
13
+ type LucideIcon,
14
+ } from "lucide-react";
15
+ import { cn } from "@/lib/utils";
16
+ import { formatBytes } from "@/lib/share/view";
17
+ import { relativeTime } from "@/lib/schedules/when";
18
+ import type {
19
+ ArtifactKind,
20
+ SessionArtifact,
21
+ } from "@/lib/session-artifacts/types";
22
+ import { folderOf, isGone, stateLabel } from "./helpers";
23
+
24
+ const ICONS: Record<ArtifactKind, LucideIcon> = {
25
+ plan: ListChecks,
26
+ pr: GitPullRequest,
27
+ run: Play,
28
+ "session-log": ScrollText,
29
+ page: AppWindow,
30
+ file: FileText,
31
+ image: ImageIcon,
32
+ log: SquareTerminal,
33
+ };
34
+
35
+ export function ArtifactIcon({ artifact }: { artifact: SessionArtifact }) {
36
+ const Icon =
37
+ artifact.kind === "file" && artifact.preview === "html"
38
+ ? FileCode2
39
+ : ICONS[artifact.kind];
40
+ const lead =
41
+ artifact.kind === "plan" ||
42
+ artifact.kind === "pr" ||
43
+ artifact.kind === "run";
44
+ return (
45
+ <span
46
+ className={cn(
47
+ "bg-muted flex h-7 w-7 shrink-0 items-center justify-center rounded-md",
48
+ lead ? "text-primary" : "text-muted-foreground"
49
+ )}
50
+ >
51
+ <Icon className="h-3.5 w-3.5" />
52
+ </span>
53
+ );
54
+ }
55
+
56
+ const MONO: ArtifactKind[] = ["file", "image"];
57
+
58
+ // The line under the name: where it is, or what it is.
59
+ function subline(a: SessionArtifact, mobile: boolean): string {
60
+ const bits: string[] = [];
61
+ if (a.by === "you") bits.push("attached by you");
62
+ if (a.detail) bits.push(a.detail);
63
+ if (mobile && a.edits)
64
+ bits.push(`${a.edits} edit${a.edits === 1 ? "" : "s"}`);
65
+ if (!bits.length && a.folder !== undefined) return a.folder;
66
+ if (!bits.length && a.path) return folderOf(a.path);
67
+ if (a.by === "shell") bits.push("made by a command");
68
+ return bits.join(" · ");
69
+ }
70
+
71
+ export function ArtifactRow({
72
+ artifact,
73
+ pr,
74
+ selected,
75
+ mobile,
76
+ onSelect,
77
+ }: {
78
+ artifact: SessionArtifact;
79
+ pr: string | null;
80
+ selected: boolean;
81
+ mobile: boolean;
82
+ onSelect: () => void;
83
+ }) {
84
+ const label = stateLabel(artifact, pr);
85
+ const size = artifact.size !== undefined ? formatBytes(artifact.size) : null;
86
+ const when = artifact.at ? relativeTime(artifact.at) : null;
87
+ return (
88
+ <button
89
+ type="button"
90
+ data-artifact-id={artifact.id}
91
+ onClick={onSelect}
92
+ aria-current={selected || undefined}
93
+ className={cn(
94
+ "hover:bg-accent/60 flex w-full items-center gap-2.5 px-3.5 py-2 text-left transition-colors",
95
+ mobile ? "min-h-14" : "min-h-12",
96
+ selected && "bg-accent shadow-[inset_2px_0_0_hsl(var(--primary))]",
97
+ isGone(artifact) && "opacity-55"
98
+ )}
99
+ >
100
+ <ArtifactIcon artifact={artifact} />
101
+ <span className="min-w-0 flex-1">
102
+ <span
103
+ className={cn(
104
+ "block truncate text-[13px]",
105
+ MONO.includes(artifact.kind) && artifact.by !== "you" && "font-mono"
106
+ )}
107
+ >
108
+ {artifact.name}
109
+ </span>
110
+ <span
111
+ className={cn(
112
+ "text-muted-foreground block truncate text-[11.5px]",
113
+ !artifact.detail && artifact.path && "font-mono"
114
+ )}
115
+ >
116
+ {subline(artifact, mobile)}
117
+ </span>
118
+ </span>
119
+ <span className="text-muted-foreground shrink-0 text-right text-[11.5px] whitespace-nowrap">
120
+ {!mobile && (artifact.edits || label) && (
121
+ <span className="block">
122
+ {artifact.edits
123
+ ? `${artifact.edits} edit${artifact.edits === 1 ? "" : "s"}`
124
+ : null}
125
+ {label && (
126
+ <span
127
+ className={cn(
128
+ "ml-1 rounded border px-1 text-[10.5px]",
129
+ artifact.state === "kept" &&
130
+ "border-emerald-500/35 text-emerald-600 dark:text-emerald-400",
131
+ artifact.state === "in-pr" && "border-primary/35 text-primary"
132
+ )}
133
+ >
134
+ {label}
135
+ </span>
136
+ )}
137
+ </span>
138
+ )}
139
+ <span className="block">
140
+ {mobile && label ? `${label} · ` : ""}
141
+ {[size, when].filter(Boolean).join(" · ")}
142
+ {mobile && " ›"}
143
+ </span>
144
+ </span>
145
+ </button>
146
+ );
147
+ }