@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.
- package/README.md +1 -0
- package/app/api/sessions/[id]/artifacts/[artifactId]/route.ts +34 -0
- package/app/api/sessions/[id]/artifacts/route.ts +25 -9
- package/components/Artifacts/ArtifactPreview.tsx +239 -0
- package/components/Artifacts/ArtifactRow.tsx +147 -0
- package/components/Artifacts/ArtifactsPanel.tsx +244 -0
- package/components/Artifacts/helpers.test.ts +90 -0
- package/components/Artifacts/helpers.ts +99 -0
- package/components/Artifacts/index.ts +1 -0
- package/components/Chat/ArtifactFrame.tsx +4 -1
- package/components/Pane/DesktopTabBar.tsx +2 -2
- package/components/Pane/MobileTabBar.tsx +14 -2
- package/components/Pane/PaneViewToggle.tsx +20 -3
- package/components/Pane/index.tsx +35 -3
- package/contexts/PaneContext.tsx +7 -1
- package/data/session-artifacts/index.ts +2 -0
- package/data/session-artifacts/keys.ts +7 -0
- package/data/session-artifacts/queries.ts +53 -0
- package/lib/chat/task-output.ts +1 -1
- package/lib/done/worktree.ts +3 -0
- package/lib/security/demo.ts +1 -0
- package/lib/session-artifacts/access.ts +81 -0
- package/lib/session-artifacts/collect.ts +318 -0
- package/lib/session-artifacts/derive.ts +437 -0
- package/lib/session-artifacts/keep.ts +119 -0
- package/lib/session-artifacts/peer.ts +187 -0
- package/lib/session-artifacts/serve.ts +265 -0
- package/lib/session-artifacts/types.ts +43 -0
- package/lib/tasks/finish.ts +2 -0
- package/package.json +1 -1
|
@@ -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 {
|
|
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
|
package/contexts/PaneContext.tsx
CHANGED
|
@@ -26,7 +26,13 @@ import {
|
|
|
26
26
|
} from "@/lib/panes";
|
|
27
27
|
import { useViewport } from "@/hooks/useViewport";
|
|
28
28
|
|
|
29
|
-
export type ViewMode =
|
|
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;
|