@saadnvd1/agent-os 0.3.595 → 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.
@@ -0,0 +1,244 @@
1
+ "use client";
2
+
3
+ import {
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type KeyboardEvent,
9
+ } from "react";
10
+ import { Package } from "lucide-react";
11
+ import { cn } from "@/lib/utils";
12
+ import {
13
+ artifactContentUrl,
14
+ useSessionArtifacts,
15
+ } from "@/data/session-artifacts";
16
+ import { openable, type SessionArtifact } from "@/lib/session-artifacts/types";
17
+ import { ArtifactPreview } from "./ArtifactPreview";
18
+ import { ArtifactRow } from "./ArtifactRow";
19
+ import {
20
+ counts,
21
+ FILTER_LABELS,
22
+ groups,
23
+ prNumber,
24
+ step,
25
+ visible,
26
+ type ArtifactFilter,
27
+ } from "./helpers";
28
+
29
+ // Images that can be shown, as tiles; the rest are rows like any file.
30
+ function ImageTiles({
31
+ sessionId,
32
+ images,
33
+ selected,
34
+ onSelect,
35
+ }: {
36
+ sessionId: string;
37
+ images: SessionArtifact[];
38
+ selected: string | null;
39
+ onSelect: (id: string) => void;
40
+ }) {
41
+ return (
42
+ <div className="grid grid-cols-3 gap-1.5 px-3.5 pt-1 pb-2.5">
43
+ {images.map((a) => (
44
+ <button
45
+ key={a.id}
46
+ type="button"
47
+ data-artifact-id={a.id}
48
+ onClick={() => onSelect(a.id)}
49
+ title={a.name}
50
+ className={cn(
51
+ "bg-muted relative aspect-[4/3] overflow-hidden rounded-lg border",
52
+ selected === a.id && "ring-primary ring-2"
53
+ )}
54
+ >
55
+ {/* eslint-disable-next-line @next/next/no-img-element */}
56
+ <img
57
+ src={artifactContentUrl(sessionId, a.id)}
58
+ alt={a.name}
59
+ loading="lazy"
60
+ className="h-full w-full object-cover"
61
+ />
62
+ <span className="bg-background/80 text-muted-foreground absolute right-0 bottom-0 left-0 truncate px-1.5 py-0.5 text-left text-[10px]">
63
+ {a.by === "you" ? "by you" : a.name}
64
+ </span>
65
+ </button>
66
+ ))}
67
+ </div>
68
+ );
69
+ }
70
+
71
+ export function ArtifactsPanel({
72
+ sessionId,
73
+ mobile,
74
+ onOpenInFiles,
75
+ }: {
76
+ sessionId: string;
77
+ mobile: boolean;
78
+ onOpenInFiles?: (path: string) => void;
79
+ }) {
80
+ const { data, isPending, isError, error } = useSessionArtifacts(sessionId);
81
+ const [filter, setFilter] = useState<ArtifactFilter>("all");
82
+ const [showGone, setShowGone] = useState(false);
83
+ const [selectedId, setSelectedId] = useState<string | null>(null);
84
+ const listRef = useRef<HTMLDivElement>(null);
85
+
86
+ const all = useMemo(() => data?.artifacts ?? [], [data]);
87
+ const list = useMemo(
88
+ () => visible(all, filter, showGone),
89
+ [all, filter, showGone]
90
+ );
91
+ const tally = useMemo(() => counts(all, showGone), [all, showGone]);
92
+ const pr = prNumber(all);
93
+ const goneCount = all.length - visible(all, "all", false).length;
94
+ // On a wide pane the first row is open until another is picked.
95
+ const selected =
96
+ all.find((a) => a.id === selectedId) ?? (mobile ? null : (list[0] ?? null));
97
+ const selectedKey = selected?.id ?? null;
98
+
99
+ useEffect(() => {
100
+ if (!selectedKey) return;
101
+ listRef.current
102
+ ?.querySelector(`[data-artifact-id="${selectedKey}"]`)
103
+ ?.scrollIntoView({ block: "nearest" });
104
+ }, [selectedKey]);
105
+
106
+ const onKeyDown = (e: KeyboardEvent) => {
107
+ if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
108
+ e.preventDefault();
109
+ setSelectedId(step(list, selectedKey, e.key === "ArrowDown" ? 1 : -1));
110
+ };
111
+
112
+ if (isPending)
113
+ return <div className="bg-muted/40 m-4 h-40 animate-pulse rounded-lg" />;
114
+ if (isError)
115
+ return <p className="text-destructive p-4 text-sm">{error.message}</p>;
116
+
117
+ if (mobile && selected)
118
+ return (
119
+ <ArtifactPreview
120
+ sessionId={sessionId}
121
+ artifact={selected}
122
+ pr={pr}
123
+ mobile
124
+ onBack={() => setSelectedId(null)}
125
+ onOpenInFiles={onOpenInFiles}
126
+ />
127
+ );
128
+
129
+ const header = (
130
+ <div className="bg-background sticky top-0 z-10 px-3.5 pt-3 pb-2">
131
+ <div className="flex items-baseline gap-1.5 text-[13.5px] font-semibold">
132
+ Artifacts
133
+ <span className="text-muted-foreground font-normal">{tally.all}</span>
134
+ {goneCount > 0 && (
135
+ <button
136
+ type="button"
137
+ onClick={() => setShowGone(!showGone)}
138
+ className="text-muted-foreground hover:text-foreground ml-auto py-1 text-xs font-normal"
139
+ >
140
+ {showGone ? "Hide gone" : `Show gone (${goneCount})`}
141
+ </button>
142
+ )}
143
+ </div>
144
+ <div className="scrollbar-none mt-2.5 flex gap-1.5 overflow-x-auto">
145
+ {(Object.keys(FILTER_LABELS) as ArtifactFilter[])
146
+ .filter((f) => f === "all" || tally[f])
147
+ .map((f) => (
148
+ <button
149
+ key={f}
150
+ type="button"
151
+ onClick={() => setFilter(f)}
152
+ className={cn(
153
+ "shrink-0 rounded-full border px-2.5 py-1 text-xs whitespace-nowrap",
154
+ mobile && "min-h-9 px-3",
155
+ filter === f
156
+ ? "bg-foreground text-background border-transparent"
157
+ : "text-muted-foreground"
158
+ )}
159
+ >
160
+ {FILTER_LABELS[f]}
161
+ {f !== "all" && ` ${tally[f]}`}
162
+ </button>
163
+ ))}
164
+ </div>
165
+ </div>
166
+ );
167
+
168
+ const empty = (
169
+ <div className="text-muted-foreground flex flex-col items-center gap-2 px-6 py-12 text-center text-sm">
170
+ <Package className="h-5 w-5" />
171
+ {data.unreachable
172
+ ? `This session runs on ${data.host}, which isn't reachable right now.`
173
+ : all.length
174
+ ? "Nothing here with these filters."
175
+ : "Nothing yet. Files this session writes, pages it renders, images, job logs and its PR show up here."}
176
+ </div>
177
+ );
178
+
179
+ const rows = (
180
+ <div
181
+ ref={listRef}
182
+ tabIndex={mobile ? undefined : 0}
183
+ onKeyDown={mobile ? undefined : onKeyDown}
184
+ className="h-full overflow-y-auto outline-none"
185
+ >
186
+ {header}
187
+ {list.length === 0
188
+ ? empty
189
+ : groups(list).map((g) => {
190
+ const tiles =
191
+ g.label === "Images" ? g.items.filter((a) => openable(a)) : [];
192
+ const rest = g.items.filter((a) => !tiles.includes(a));
193
+ return (
194
+ <section key={g.label}>
195
+ <h3 className="text-muted-foreground px-3.5 pt-3 pb-1 text-[11px] font-medium tracking-wider uppercase">
196
+ {g.label}
197
+ {g.label === "Images" && ` · ${g.items.length}`}
198
+ </h3>
199
+ {tiles.length > 0 && (
200
+ <ImageTiles
201
+ sessionId={sessionId}
202
+ images={tiles}
203
+ selected={selectedKey}
204
+ onSelect={setSelectedId}
205
+ />
206
+ )}
207
+ {rest.map((a) => (
208
+ <ArtifactRow
209
+ key={a.id}
210
+ artifact={a}
211
+ pr={pr}
212
+ mobile={mobile}
213
+ selected={a.id === selectedKey}
214
+ onSelect={() => setSelectedId(a.id)}
215
+ />
216
+ ))}
217
+ </section>
218
+ );
219
+ })}
220
+ </div>
221
+ );
222
+
223
+ if (mobile) return rows;
224
+ return (
225
+ <div className="flex h-full min-h-0">
226
+ <div className="w-[340px] shrink-0 border-r">{rows}</div>
227
+ <div className="min-w-0 flex-1">
228
+ {selected ? (
229
+ <ArtifactPreview
230
+ sessionId={sessionId}
231
+ artifact={selected}
232
+ pr={pr}
233
+ mobile={false}
234
+ onOpenInFiles={onOpenInFiles}
235
+ />
236
+ ) : (
237
+ <p className="text-muted-foreground p-6 text-sm">
238
+ Select an artifact to preview it.
239
+ </p>
240
+ )}
241
+ </div>
242
+ </div>
243
+ );
244
+ }
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { SessionArtifact } from "@/lib/session-artifacts/types";
3
+ import {
4
+ counts,
5
+ folderOf,
6
+ groups,
7
+ prNumber,
8
+ stateLabel,
9
+ step,
10
+ visible,
11
+ } from "./helpers";
12
+
13
+ const a = (
14
+ id: string,
15
+ kind: SessionArtifact["kind"],
16
+ state: SessionArtifact["state"] = "live"
17
+ ): SessionArtifact => ({
18
+ id,
19
+ kind,
20
+ name: id,
21
+ at: 0,
22
+ state,
23
+ preview: "text",
24
+ });
25
+
26
+ const all = [
27
+ a("plan", "plan"),
28
+ { ...a("pr", "pr"), name: "PR #231" },
29
+ a("f1", "file"),
30
+ a("f2", "file", "in-pr"),
31
+ a("f3", "file", "gone"),
32
+ a("p1", "page"),
33
+ a("i1", "image", "kept"),
34
+ a("l1", "log"),
35
+ ];
36
+
37
+ describe("artifacts view", () => {
38
+ it("hides what's gone unless asked, and filters by type", () => {
39
+ expect(visible(all, "all", false).map((x) => x.id)).toEqual([
40
+ "plan",
41
+ "pr",
42
+ "f1",
43
+ "p1",
44
+ "i1",
45
+ "l1",
46
+ ]);
47
+ expect(visible(all, "files", true).map((x) => x.id)).toEqual([
48
+ "f1",
49
+ "f2",
50
+ "f3",
51
+ ]);
52
+ expect(counts(all, false)).toEqual({
53
+ all: 6,
54
+ files: 1,
55
+ pages: 1,
56
+ images: 1,
57
+ logs: 1,
58
+ });
59
+ });
60
+
61
+ it("groups in the panel's order, leaving out empty groups", () => {
62
+ expect(
63
+ groups(visible(all, "all", false)).map((g) => [g.label, g.items.length])
64
+ ).toEqual([
65
+ ["Plan & PR", 2],
66
+ ["Pages", 1],
67
+ ["Files", 1],
68
+ ["Images", 1],
69
+ ["Logs", 1],
70
+ ]);
71
+ });
72
+
73
+ it("labels where a file is", () => {
74
+ expect(prNumber(all)).toBe("231");
75
+ expect(stateLabel(all[3], "231")).toBe("in PR #231");
76
+ expect(stateLabel(all[3], null)).toBe("committed");
77
+ expect(stateLabel(all[6], null)).toBe("kept copy");
78
+ expect(stateLabel(all[2], null)).toBeNull();
79
+ expect(folderOf("~/r/src/a.ts")).toBe("~/r/src/");
80
+ });
81
+
82
+ it("moves with the arrow keys without wrapping", () => {
83
+ const list = all.slice(0, 3);
84
+ expect(step(list, null, 1)).toBe("plan");
85
+ expect(step(list, "plan", 1)).toBe("pr");
86
+ expect(step(list, "plan", -1)).toBe("plan");
87
+ expect(step(list, "f1", 1)).toBe("f1");
88
+ expect(step([], null, 1)).toBeNull();
89
+ });
90
+ });
@@ -0,0 +1,99 @@
1
+ import type {
2
+ ArtifactKind,
3
+ SessionArtifact,
4
+ } from "@/lib/session-artifacts/types";
5
+
6
+ export type ArtifactFilter = "all" | "files" | "pages" | "images" | "logs";
7
+
8
+ const FILTER_KINDS: Record<Exclude<ArtifactFilter, "all">, ArtifactKind[]> = {
9
+ files: ["file"],
10
+ pages: ["page"],
11
+ images: ["image"],
12
+ logs: ["log"],
13
+ };
14
+
15
+ export const FILTER_LABELS: Record<ArtifactFilter, string> = {
16
+ all: "All",
17
+ files: "Files",
18
+ pages: "Pages",
19
+ images: "Images",
20
+ logs: "Logs",
21
+ };
22
+
23
+ const GROUPS: { label: string; kinds: ArtifactKind[] }[] = [
24
+ { label: "Plan & PR", kinds: ["plan", "pr", "run", "session-log"] },
25
+ { label: "Pages", kinds: ["page"] },
26
+ { label: "Files", kinds: ["file"] },
27
+ { label: "Images", kinds: ["image"] },
28
+ { label: "Logs", kinds: ["log"] },
29
+ ];
30
+
31
+ export const isGone = (a: SessionArtifact) =>
32
+ a.state === "gone" || a.state === "in-pr";
33
+
34
+ export function visible(
35
+ all: SessionArtifact[],
36
+ filter: ArtifactFilter,
37
+ showGone: boolean
38
+ ): SessionArtifact[] {
39
+ return all.filter(
40
+ (a) =>
41
+ (showGone || !isGone(a)) &&
42
+ (filter === "all" || FILTER_KINDS[filter].includes(a.kind))
43
+ );
44
+ }
45
+
46
+ export function groups(list: SessionArtifact[]) {
47
+ return GROUPS.map((g) => ({
48
+ label: g.label,
49
+ items: list.filter((a) => g.kinds.includes(a.kind)),
50
+ })).filter((g) => g.items.length);
51
+ }
52
+
53
+ // How many of each filter there are, among what's shown.
54
+ export function counts(all: SessionArtifact[], showGone: boolean) {
55
+ const shown = visible(all, "all", showGone);
56
+ return Object.fromEntries(
57
+ (Object.keys(FILTER_LABELS) as ArtifactFilter[]).map((f) => [
58
+ f,
59
+ visible(shown, f, true).length,
60
+ ])
61
+ ) as Record<ArtifactFilter, number>;
62
+ }
63
+
64
+ // The PR's number, for "in PR #n".
65
+ export const prNumber = (all: SessionArtifact[]) =>
66
+ all.find((a) => a.kind === "pr")?.name.match(/#(\d+)/)?.[1] ?? null;
67
+
68
+ export function stateLabel(a: SessionArtifact, pr: string | null) {
69
+ switch (a.state) {
70
+ case "kept":
71
+ return "kept copy";
72
+ case "in-pr":
73
+ return pr ? `in PR #${pr}` : "committed";
74
+ case "gone":
75
+ return "gone";
76
+ case "outside":
77
+ return "outside";
78
+ default:
79
+ return null;
80
+ }
81
+ }
82
+
83
+ // The folder a file is in, for the line under its name.
84
+ export const folderOf = (p: string) => {
85
+ const i = p.lastIndexOf("/");
86
+ return i > 0 ? p.slice(0, i + 1) : p;
87
+ };
88
+
89
+ // The next row for the arrow keys, wrapping at neither end.
90
+ export function step(
91
+ list: SessionArtifact[],
92
+ current: string | null,
93
+ by: 1 | -1
94
+ ): string | null {
95
+ if (!list.length) return null;
96
+ const i = list.findIndex((a) => a.id === current);
97
+ if (i === -1) return list[0].id;
98
+ return list[Math.min(list.length - 1, Math.max(0, i + by))].id;
99
+ }
@@ -0,0 +1 @@
1
+ export { ArtifactsPanel } from "./ArtifactsPanel";
@@ -25,12 +25,15 @@ export function artifactHeight(data: unknown, max: number): number | null {
25
25
  // the bounds; filling, it takes its container.
26
26
  export function ArtifactFrame({
27
27
  artifactId,
28
+ src,
28
29
  title,
29
30
  fill = false,
30
31
  maxHeight = 720,
31
32
  className,
32
33
  }: {
33
34
  artifactId: string;
35
+ // Another sandboxed URL for the same kind of page (a session's HTML file).
36
+ src?: string;
34
37
  title: string;
35
38
  fill?: boolean;
36
39
  maxHeight?: number;
@@ -62,7 +65,7 @@ export function ArtifactFrame({
62
65
  return (
63
66
  <iframe
64
67
  ref={ref}
65
- src={artifactUrl(artifactId)}
68
+ src={src ?? artifactUrl(artifactId)}
66
69
  title={title}
67
70
  sandbox={ARTIFACT_IFRAME_SANDBOX}
68
71
  referrerPolicy="no-referrer"
@@ -17,8 +17,7 @@ import { PaneMenu } from "./PaneMenu";
17
17
  import { HostBadge } from "@/components/Hosts/HostBadge";
18
18
  import { usePaneBarSlots } from "./PaneBarSlots";
19
19
  import { ChatBookmarksButton } from "@/components/Bookmarks/ChatBookmarksButton";
20
-
21
- type ViewMode = "terminal" | "files" | "git" | "workers" | "log";
20
+ import type { ViewMode } from "@/contexts/PaneContext";
22
21
 
23
22
  interface Tab {
24
23
  id: string;
@@ -172,6 +171,7 @@ export function DesktopTabBar({
172
171
  )}
173
172
  {session?.working_directory && (
174
173
  <PaneViewToggle
174
+ sessionId={session.id}
175
175
  viewMode={viewMode}
176
176
  isLocalSession={isLocalSession}
177
177
  isConductor={isConductor}
@@ -28,6 +28,7 @@ import {
28
28
  TextSearch,
29
29
  Users,
30
30
  ScrollText,
31
+ Package,
31
32
  } from "lucide-react";
32
33
  import { cn } from "@/lib/utils";
33
34
  import { useAwayQuery } from "@/data/away";
@@ -38,13 +39,13 @@ import { HostBadge } from "@/components/Hosts/HostBadge";
38
39
  import { shareUiActions } from "@/stores/shareUi";
39
40
  import { MoveMenuItems } from "@/components/Tasks/MoveMenuItems";
40
41
  import { ChatBookmarksButton } from "@/components/Bookmarks/ChatBookmarksButton";
41
-
42
- type ViewMode = "terminal" | "files" | "git" | "workers" | "log";
42
+ import type { ViewMode } from "@/contexts/PaneContext";
43
43
 
44
44
  interface ViewModeButtonProps {
45
45
  mode: ViewMode;
46
46
  currentMode: ViewMode;
47
47
  icon: LucideIcon;
48
+ label?: string;
48
49
  onClick: (mode: ViewMode) => void;
49
50
  badge?: React.ReactNode;
50
51
  }
@@ -53,11 +54,15 @@ function ViewModeButton({
53
54
  mode,
54
55
  currentMode,
55
56
  icon: Icon,
57
+ label,
56
58
  onClick,
57
59
  badge,
58
60
  }: ViewModeButtonProps) {
59
61
  return (
60
62
  <button
63
+ type="button"
64
+ aria-label={label}
65
+ aria-pressed={currentMode === mode}
61
66
  onClick={(e) => {
62
67
  e.stopPropagation();
63
68
  onClick(mode);
@@ -378,6 +383,13 @@ export function MobileTabBar({
378
383
  onClick={onViewModeChange}
379
384
  />
380
385
  )}
386
+ <ViewModeButton
387
+ mode="artifacts"
388
+ currentMode={viewMode}
389
+ icon={Package}
390
+ label="Artifacts"
391
+ onClick={onViewModeChange}
392
+ />
381
393
  {hasLog && (
382
394
  <ViewModeButton
383
395
  mode="log"
@@ -1,16 +1,23 @@
1
1
  "use client";
2
2
 
3
- import { FolderOpen, GitBranch, Home, ScrollText, Users } from "lucide-react";
3
+ import {
4
+ FolderOpen,
5
+ GitBranch,
6
+ Home,
7
+ Package,
8
+ ScrollText,
9
+ Users,
10
+ } from "lucide-react";
4
11
  import {
5
12
  Tooltip,
6
13
  TooltipContent,
7
14
  TooltipTrigger,
8
15
  } from "@/components/ui/tooltip";
9
16
  import { cn } from "@/lib/utils";
10
-
11
- type ViewMode = "terminal" | "files" | "git" | "workers" | "log";
17
+ import type { ViewMode } from "@/contexts/PaneContext";
12
18
 
13
19
  interface PaneViewToggleProps {
20
+ sessionId?: string;
14
21
  viewMode: ViewMode;
15
22
  isLocalSession: boolean;
16
23
  isConductor: boolean;
@@ -67,6 +74,7 @@ function ToggleButton({
67
74
  // Files, git and the shell drawer read this machine's disk, so a remote
68
75
  // session only gets the terminal (and workers).
69
76
  export function PaneViewToggle({
77
+ sessionId,
70
78
  viewMode,
71
79
  isLocalSession,
72
80
  isConductor,
@@ -113,6 +121,15 @@ export function PaneViewToggle({
113
121
  </ToggleButton>
114
122
  </>
115
123
  )}
124
+ {sessionId && (
125
+ <ToggleButton
126
+ label="Artifacts"
127
+ active={viewMode === "artifacts"}
128
+ onClick={() => onViewModeChange("artifacts")}
129
+ >
130
+ <Package className="h-3.5 w-3.5" />
131
+ </ToggleButton>
132
+ )}
116
133
  {hasLog && (
117
134
  <ToggleButton
118
135
  label="Log"
@@ -7,7 +7,7 @@ import { getProviderDefinition } from "@/lib/providers/registry";
7
7
  import type { TabData } from "@/lib/panes";
8
8
  import { useRef, useCallback, useEffect, memo, useMemo } from "react";
9
9
  import dynamic from "next/dynamic";
10
- import { usePanes } from "@/contexts/PaneContext";
10
+ import { usePanes, type ViewMode } from "@/contexts/PaneContext";
11
11
  import { useViewport } from "@/hooks/useViewport";
12
12
  import type { TerminalHandle } from "@/components/Terminal";
13
13
  import type { Session, Project } from "@/lib/db";
@@ -37,6 +37,13 @@ import { useDemoMode } from "@/data/demo";
37
37
  import { DemoNotice } from "@/components/DemoNotice";
38
38
 
39
39
  // Dynamic imports for client-only components with loading states
40
+ const ArtifactsPanel = dynamic(
41
+ () =>
42
+ import("@/components/Artifacts/ArtifactsPanel").then(
43
+ (mod) => mod.ArtifactsPanel
44
+ ),
45
+ { ssr: false }
46
+ );
40
47
  const LogDocument = dynamic(
41
48
  () =>
42
49
  import("@/components/SessionLogs/LogDocument").then(
@@ -98,8 +105,6 @@ interface PaneProps {
98
105
  onAttachSession?: (terminal: TerminalHandle, session: Session) => void;
99
106
  }
100
107
 
101
- type ViewMode = "terminal" | "files" | "git" | "workers" | "log";
102
-
103
108
  export const Pane = memo(function Pane({
104
109
  paneId,
105
110
  sessions,
@@ -231,6 +236,15 @@ export const Pane = memo(function Pane({
231
236
  }
232
237
  }, [fileOpenRequest, isFocused, session, fileEditor]);
233
238
 
239
+ // An artifact's file, opened in the Files view.
240
+ const openInFiles = useCallback(
241
+ (path: string) => {
242
+ setViewMode("files");
243
+ fileEditor.openFile(path);
244
+ },
245
+ [setViewMode, fileEditor]
246
+ );
247
+
234
248
  const handleFocus = useCallback(() => {
235
249
  focusPane(paneId);
236
250
  }, [focusPane, paneId]);
@@ -524,6 +538,15 @@ export const Pane = memo(function Pane({
524
538
  <LogDocument sessionId={session.id} />
525
539
  )}
526
540
 
541
+ {/* Artifacts */}
542
+ {viewMode === "artifacts" && session && (
543
+ <ArtifactsPanel
544
+ sessionId={session.id}
545
+ mobile
546
+ onOpenInFiles={isLocalSession ? openInFiles : undefined}
547
+ />
548
+ )}
549
+
527
550
  {/* Workers */}
528
551
  {viewMode === "workers" && session && (
529
552
  <ConductorPanel
@@ -586,6 +609,15 @@ export const Pane = memo(function Pane({
586
609
  <LogDocument sessionId={session.id} />
587
610
  )}
588
611
 
612
+ {/* Artifacts */}
613
+ {viewMode === "artifacts" && session && (
614
+ <ArtifactsPanel
615
+ sessionId={session.id}
616
+ mobile={false}
617
+ onOpenInFiles={isLocalSession ? openInFiles : undefined}
618
+ />
619
+ )}
620
+
589
621
  {/* Workers */}
590
622
  {viewMode === "workers" && session && (
591
623
  <ConductorPanel
@@ -26,7 +26,13 @@ import {
26
26
  } from "@/lib/panes";
27
27
  import { useViewport } from "@/hooks/useViewport";
28
28
 
29
- export type ViewMode = "terminal" | "files" | "git" | "workers" | "log";
29
+ export type ViewMode =
30
+ | "terminal"
31
+ | "files"
32
+ | "git"
33
+ | "workers"
34
+ | "log"
35
+ | "artifacts";
30
36
 
31
37
  export interface PaneViewState {
32
38
  viewMode: ViewMode;
@@ -0,0 +1,2 @@
1
+ export * from "./keys";
2
+ export * from "./queries";
@@ -0,0 +1,7 @@
1
+ export const sessionArtifactKeys = {
2
+ all: ["session-artifacts"] as const,
3
+ session: (sessionId: string) =>
4
+ [...sessionArtifactKeys.all, sessionId] as const,
5
+ content: (url: string) =>
6
+ [...sessionArtifactKeys.all, "content", url] as const,
7
+ };