@saadnvd1/agent-os 0.3.558 → 0.3.561
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/route.ts +3 -3
- package/app/api/shares/[id]/route.ts +23 -0
- package/app/api/shares/preview/route.ts +30 -0
- package/app/api/shares/route.test.ts +455 -0
- package/app/api/shares/route.ts +101 -0
- package/app/page.tsx +5 -0
- package/components/Chat/Artifact.tsx +17 -1
- package/components/Pane/MobileTabBar.tsx +19 -0
- package/components/Pane/PaneMenu.tsx +16 -0
- package/components/SessionList/SessionRowMenu.tsx +16 -0
- package/components/Settings/SettingsDialog.test.ts +3 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Settings/commands.ts +9 -0
- package/components/Settings/entry-points.test.ts +1 -0
- package/components/Share/ShareDialog.tsx +336 -0
- package/components/Share/format.test.ts +20 -0
- package/components/Share/format.ts +33 -0
- package/components/Shares/SharedLinksPanel.tsx +147 -0
- package/data/shares/index.ts +2 -0
- package/data/shares/keys.ts +4 -0
- package/data/shares/queries.ts +111 -0
- package/lib/chat/drivers/claude.ts +5 -4
- package/lib/connect/control-api.ts +84 -0
- package/lib/db/migrations.ts +50 -0
- package/lib/db/queries.ts +4 -3
- package/lib/models/plan-step.ts +107 -15
- package/lib/orchestrator/conditions.ts +12 -4
- package/lib/orchestrator/events.ts +23 -0
- package/lib/orchestrator/reconcile.ts +201 -0
- package/lib/orchestrator/untrusted.ts +12 -2
- package/lib/plans/features.ts +69 -13
- package/lib/plans/pieces.ts +70 -15
- package/lib/plans/release.ts +46 -4
- package/lib/recipes/settle.ts +3 -0
- package/lib/security/demo.ts +1 -0
- package/lib/session-audit/normalize.ts +1 -1
- package/lib/settings-url.ts +1 -0
- package/lib/share/connect.ts +86 -0
- package/lib/share/context.ts +50 -0
- package/lib/share/redact.ts +190 -0
- package/lib/share/render.tsx +161 -0
- package/lib/share/route.ts +33 -0
- package/lib/share/service.ts +159 -0
- package/lib/share/snapshot.ts +261 -0
- package/lib/share/static-markup.ts +16 -0
- package/lib/share/store.ts +39 -0
- package/lib/share/styles.ts +83 -0
- package/lib/sidebar/shelves.ts +5 -6
- package/package.json +1 -1
- package/stores/shareUi.ts +17 -0
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
Menu,
|
|
24
24
|
Plus,
|
|
25
25
|
Search,
|
|
26
|
+
Share2,
|
|
26
27
|
Terminal as TerminalIcon,
|
|
27
28
|
TextSearch,
|
|
28
29
|
Users,
|
|
@@ -34,6 +35,7 @@ import { awayUntil } from "@/components/Away/time";
|
|
|
34
35
|
import type { Session, Project } from "@/lib/db";
|
|
35
36
|
import type { LucideIcon } from "lucide-react";
|
|
36
37
|
import { HostBadge } from "@/components/Hosts/HostBadge";
|
|
38
|
+
import { shareUiActions } from "@/stores/shareUi";
|
|
37
39
|
import { MoveMenuItems } from "@/components/Tasks/MoveMenuItems";
|
|
38
40
|
|
|
39
41
|
type ViewMode = "terminal" | "files" | "git" | "workers" | "log";
|
|
@@ -234,6 +236,23 @@ export function MobileTabBar({
|
|
|
234
236
|
iconClassName="h-4 w-4"
|
|
235
237
|
/>
|
|
236
238
|
)}
|
|
239
|
+
{session?.view === "chat" && (
|
|
240
|
+
<>
|
|
241
|
+
<DropdownMenuItem
|
|
242
|
+
onSelect={() =>
|
|
243
|
+
shareUiActions.open({
|
|
244
|
+
kind: "chat",
|
|
245
|
+
sessionId: session.id,
|
|
246
|
+
title: session.name,
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
>
|
|
250
|
+
<Share2 className="h-4 w-4" />
|
|
251
|
+
Share…
|
|
252
|
+
</DropdownMenuItem>
|
|
253
|
+
<DropdownMenuSeparator />
|
|
254
|
+
</>
|
|
255
|
+
)}
|
|
237
256
|
{sessions
|
|
238
257
|
.filter((s) => !s.conductor_session_id)
|
|
239
258
|
.map((s) => {
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
Copy,
|
|
6
6
|
Link,
|
|
7
7
|
MoreHorizontal,
|
|
8
|
+
Share2,
|
|
8
9
|
SplitSquareHorizontal,
|
|
9
10
|
SplitSquareVertical,
|
|
10
11
|
Unplug,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
} from "@/components/ui/dropdown-menu";
|
|
22
23
|
import { copySessionLink, writeClipboard } from "@/hooks/useCopyToClipboard";
|
|
23
24
|
import type { Session } from "@/lib/db";
|
|
25
|
+
import { shareUiActions } from "@/stores/shareUi";
|
|
24
26
|
import { MoveMenuItems } from "@/components/Tasks/MoveMenuItems";
|
|
25
27
|
|
|
26
28
|
interface PaneMenuProps {
|
|
@@ -87,6 +89,20 @@ export function PaneMenu({
|
|
|
87
89
|
<Copy className="h-4 w-4" />
|
|
88
90
|
Copy session ID
|
|
89
91
|
</DropdownMenuItem>
|
|
92
|
+
{session.view === "chat" && (
|
|
93
|
+
<DropdownMenuItem
|
|
94
|
+
onSelect={() =>
|
|
95
|
+
shareUiActions.open({
|
|
96
|
+
kind: "chat",
|
|
97
|
+
sessionId: session.id,
|
|
98
|
+
title: session.name,
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
>
|
|
102
|
+
<Share2 className="h-4 w-4" />
|
|
103
|
+
Share…
|
|
104
|
+
</DropdownMenuItem>
|
|
105
|
+
)}
|
|
90
106
|
<DropdownMenuSeparator />
|
|
91
107
|
<MoveMenuItems
|
|
92
108
|
session={session}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
GitPullRequest,
|
|
14
14
|
KanbanSquare,
|
|
15
15
|
Link,
|
|
16
|
+
Share2,
|
|
16
17
|
Loader2,
|
|
17
18
|
Pencil,
|
|
18
19
|
Pin,
|
|
@@ -38,6 +39,7 @@ import { SNOOZE_PRESETS, snoozePreset, wakeLabel } from "@/lib/sidebar/snooze";
|
|
|
38
39
|
import type { SidebarRow } from "@/lib/sidebar/shelves";
|
|
39
40
|
import { useRowContext } from "./RowContext";
|
|
40
41
|
import { MoveMenuItems } from "@/components/Tasks/MoveMenuItems";
|
|
42
|
+
import { shareUiActions } from "@/stores/shareUi";
|
|
41
43
|
import { copySessionLink } from "@/hooks/useCopyToClipboard";
|
|
42
44
|
import { useLinkedHostNames } from "@/data/hosts";
|
|
43
45
|
import { remoteBlocks } from "@/lib/hosts/remote-menu";
|
|
@@ -206,6 +208,20 @@ export function SessionRowMenu({
|
|
|
206
208
|
<Link className={icon} />
|
|
207
209
|
Copy link
|
|
208
210
|
</Item>
|
|
211
|
+
{session.view === "chat" && (
|
|
212
|
+
<Item
|
|
213
|
+
onClick={() =>
|
|
214
|
+
shareUiActions.open({
|
|
215
|
+
kind: "chat",
|
|
216
|
+
sessionId: session.id,
|
|
217
|
+
title: session.name,
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
>
|
|
221
|
+
<Share2 className={icon} />
|
|
222
|
+
Share…
|
|
223
|
+
</Item>
|
|
224
|
+
)}
|
|
209
225
|
<Item onClick={onRename}>
|
|
210
226
|
<Pencil className={icon} />
|
|
211
227
|
Rename
|
|
@@ -38,6 +38,9 @@ vi.mock("@/components/Models/ModelsSection", () => ({
|
|
|
38
38
|
ModelsSection: stub("models"),
|
|
39
39
|
}));
|
|
40
40
|
vi.mock("@/components/Devices", () => ({ DevicesPanel: stub("devices") }));
|
|
41
|
+
vi.mock("@/components/Shares/SharedLinksPanel", () => ({
|
|
42
|
+
SharedLinksPanel: stub("shares"),
|
|
43
|
+
}));
|
|
41
44
|
vi.mock("@/components/Schedules", () => ({
|
|
42
45
|
SchedulesPanel: stub("schedules"),
|
|
43
46
|
}));
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
Cpu,
|
|
13
13
|
GitMerge,
|
|
14
14
|
LayoutGrid,
|
|
15
|
+
Link2,
|
|
15
16
|
ListChecks,
|
|
16
17
|
ShieldOff,
|
|
17
18
|
FlaskConical,
|
|
@@ -32,6 +33,7 @@ import { AuditsPanel } from "@/components/Audits/AuditsPanel";
|
|
|
32
33
|
import { DevicesPanel } from "@/components/Devices";
|
|
33
34
|
import { GlobalMergeSection } from "@/components/Merge/GlobalMergeSection";
|
|
34
35
|
import { ModelsSection } from "@/components/Models/ModelsSection";
|
|
36
|
+
import { SharedLinksPanel } from "@/components/Shares/SharedLinksPanel";
|
|
35
37
|
import { SchedulesPanel } from "@/components/Schedules";
|
|
36
38
|
import { UpdatesSection } from "@/components/Updates/UpdatesSection";
|
|
37
39
|
import { ExperimentsSection } from "./ExperimentsSection";
|
|
@@ -73,6 +75,11 @@ export const SECTION_INFO: Record<
|
|
|
73
75
|
hint: "Pairing, passkeys, network",
|
|
74
76
|
icon: Smartphone,
|
|
75
77
|
},
|
|
78
|
+
shares: {
|
|
79
|
+
label: "Shared links",
|
|
80
|
+
hint: "Snapshot links you made, with views, and revoke",
|
|
81
|
+
icon: Link2,
|
|
82
|
+
},
|
|
76
83
|
models: {
|
|
77
84
|
label: "Models",
|
|
78
85
|
hint: "Which model each kind of work runs on, and providers",
|
|
@@ -133,6 +140,8 @@ function SectionBody({
|
|
|
133
140
|
return <ModelsSection />;
|
|
134
141
|
case "devices":
|
|
135
142
|
return <DevicesPanel />;
|
|
143
|
+
case "shares":
|
|
144
|
+
return <SharedLinksPanel />;
|
|
136
145
|
case "notifications":
|
|
137
146
|
return <NotificationsSection browser={browser} />;
|
|
138
147
|
case "recipes":
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
FlaskConical,
|
|
7
7
|
GitMerge,
|
|
8
8
|
LayoutGrid,
|
|
9
|
+
Link2,
|
|
9
10
|
RefreshCw,
|
|
10
11
|
Settings,
|
|
11
12
|
Smartphone,
|
|
@@ -72,6 +73,14 @@ export function settingsCommands(workspaceId: string | null): PaletteCommand[] {
|
|
|
72
73
|
icon: Smartphone,
|
|
73
74
|
run: () => settingsUiActions.open("devices"),
|
|
74
75
|
},
|
|
76
|
+
{
|
|
77
|
+
id: "app.shares",
|
|
78
|
+
title: "Shared links",
|
|
79
|
+
group,
|
|
80
|
+
keywords: ["share", "snapshot", "revoke", "link", "connect"],
|
|
81
|
+
icon: Link2,
|
|
82
|
+
run: () => settingsUiActions.open("shares"),
|
|
83
|
+
},
|
|
75
84
|
{
|
|
76
85
|
id: "app.notifications",
|
|
77
86
|
title: "Notification settings",
|
|
@@ -23,6 +23,7 @@ describe("⌘K entry points", () => {
|
|
|
23
23
|
["app.workspace-settings", "workspaces"],
|
|
24
24
|
["app.models", "models"],
|
|
25
25
|
["app.devices", "devices"],
|
|
26
|
+
["app.shares", "shares"],
|
|
26
27
|
["app.notifications", "notifications"],
|
|
27
28
|
["app.phone-notifications", "notifications"],
|
|
28
29
|
["app.schedules", "schedules"],
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { useSnapshot } from "valtio";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
import { Check, Copy, Download, ExternalLink, Link2 } from "lucide-react";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import {
|
|
9
|
+
Dialog,
|
|
10
|
+
DialogContent,
|
|
11
|
+
DialogDescription,
|
|
12
|
+
DialogTitle,
|
|
13
|
+
} from "@/components/ui/dialog";
|
|
14
|
+
import {
|
|
15
|
+
Select,
|
|
16
|
+
SelectContent,
|
|
17
|
+
SelectItem,
|
|
18
|
+
SelectTrigger,
|
|
19
|
+
SelectValue,
|
|
20
|
+
} from "@/components/ui/select";
|
|
21
|
+
import { Switch } from "@/components/ui/switch";
|
|
22
|
+
import { writeClipboard } from "@/hooks/useCopyToClipboard";
|
|
23
|
+
import {
|
|
24
|
+
useCreateShare,
|
|
25
|
+
useSharePreview,
|
|
26
|
+
type ShareExpiry,
|
|
27
|
+
type SharePreview,
|
|
28
|
+
} from "@/data/shares";
|
|
29
|
+
import { shareUi, shareUiActions } from "@/stores/shareUi";
|
|
30
|
+
import { redactionSummary, formatBytes, fileName } from "./format";
|
|
31
|
+
|
|
32
|
+
const EXPIRIES: { value: ShareExpiry; label: string }[] = [
|
|
33
|
+
{ value: "1d", label: "1 day" },
|
|
34
|
+
{ value: "7d", label: "7 days" },
|
|
35
|
+
{ value: "30d", label: "30 days" },
|
|
36
|
+
{ value: "never", label: "Never" },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const TYPE_LABEL: Record<string, string> = {
|
|
40
|
+
user: "You",
|
|
41
|
+
assistant: "Agent",
|
|
42
|
+
plan: "Plan",
|
|
43
|
+
error: "Error",
|
|
44
|
+
divider: "Divider",
|
|
45
|
+
artifact: "Page",
|
|
46
|
+
detail: "Tool",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Opened from a menu or an artifact: the redacted page exactly as it would be
|
|
50
|
+
// uploaded, with what to leave out, then Create link or Download HTML.
|
|
51
|
+
export function ShareDialog() {
|
|
52
|
+
const { target } = useSnapshot(shareUi);
|
|
53
|
+
return (
|
|
54
|
+
<Dialog
|
|
55
|
+
open={!!target}
|
|
56
|
+
onOpenChange={(open) => !open && shareUiActions.close()}
|
|
57
|
+
>
|
|
58
|
+
{target && (
|
|
59
|
+
<ShareBody
|
|
60
|
+
key={target.sessionId ?? target.artifactId}
|
|
61
|
+
target={{ ...target }}
|
|
62
|
+
/>
|
|
63
|
+
)}
|
|
64
|
+
</Dialog>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ShareBody({
|
|
69
|
+
target,
|
|
70
|
+
}: {
|
|
71
|
+
target: {
|
|
72
|
+
kind: "chat" | "artifact";
|
|
73
|
+
sessionId?: string;
|
|
74
|
+
artifactId?: string;
|
|
75
|
+
title: string;
|
|
76
|
+
};
|
|
77
|
+
}) {
|
|
78
|
+
const [tools, setTools] = useState(false);
|
|
79
|
+
const [dropped, setDropped] = useState<string[]>([]);
|
|
80
|
+
const [expiresIn, setExpiresIn] = useState<ShareExpiry>("7d");
|
|
81
|
+
const [preview, setPreview] = useState<SharePreview | null>(null);
|
|
82
|
+
const [error, setError] = useState<string | null>(null);
|
|
83
|
+
const [created, setCreated] = useState<string | null>(null);
|
|
84
|
+
const [copied, setCopied] = useState(false);
|
|
85
|
+
const previewReq = useSharePreview();
|
|
86
|
+
const create = useCreateShare();
|
|
87
|
+
const seq = useRef(0);
|
|
88
|
+
|
|
89
|
+
const { kind, sessionId, artifactId } = target;
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
const mine = ++seq.current;
|
|
92
|
+
const timer = setTimeout(async () => {
|
|
93
|
+
try {
|
|
94
|
+
const p = await previewReq.mutateAsync({
|
|
95
|
+
kind,
|
|
96
|
+
sessionId,
|
|
97
|
+
artifactId,
|
|
98
|
+
includeTools: tools,
|
|
99
|
+
dropIds: dropped,
|
|
100
|
+
});
|
|
101
|
+
if (mine === seq.current) {
|
|
102
|
+
setPreview(p);
|
|
103
|
+
setError(null);
|
|
104
|
+
}
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (mine === seq.current) setError((err as Error).message);
|
|
107
|
+
}
|
|
108
|
+
}, 250);
|
|
109
|
+
return () => clearTimeout(timer);
|
|
110
|
+
// previewReq is stable enough; re-running on it would loop.
|
|
111
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
112
|
+
}, [kind, sessionId, artifactId, tools, dropped]);
|
|
113
|
+
|
|
114
|
+
const toggle = (id: string) =>
|
|
115
|
+
setDropped((d) =>
|
|
116
|
+
d.includes(id) ? d.filter((x) => x !== id) : [...d, id]
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const makeLink = async () => {
|
|
120
|
+
if (!preview) return;
|
|
121
|
+
try {
|
|
122
|
+
const res = await create.mutateAsync({
|
|
123
|
+
kind,
|
|
124
|
+
sessionId,
|
|
125
|
+
artifactId,
|
|
126
|
+
includeTools: tools,
|
|
127
|
+
dropIds: dropped,
|
|
128
|
+
sharedAt: preview.sharedAt,
|
|
129
|
+
hash: preview.hash,
|
|
130
|
+
expiresIn,
|
|
131
|
+
});
|
|
132
|
+
setCreated(res.url);
|
|
133
|
+
try {
|
|
134
|
+
await writeClipboard(res.url);
|
|
135
|
+
toast.success("Link copied");
|
|
136
|
+
} catch {
|
|
137
|
+
toast.message("Link created. Copy it below.");
|
|
138
|
+
}
|
|
139
|
+
} catch (err) {
|
|
140
|
+
setError((err as Error).message);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const download = () => {
|
|
145
|
+
if (!preview) return;
|
|
146
|
+
const url = URL.createObjectURL(
|
|
147
|
+
new Blob([preview.html], { type: "text/html" })
|
|
148
|
+
);
|
|
149
|
+
const a = document.createElement("a");
|
|
150
|
+
a.href = url;
|
|
151
|
+
a.download = fileName(target.title);
|
|
152
|
+
a.click();
|
|
153
|
+
URL.revokeObjectURL(url);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const copyAgain = async () => {
|
|
157
|
+
if (!created) return;
|
|
158
|
+
try {
|
|
159
|
+
await writeClipboard(created);
|
|
160
|
+
setCopied(true);
|
|
161
|
+
setTimeout(() => setCopied(false), 1500);
|
|
162
|
+
} catch {
|
|
163
|
+
toast.error("Could not copy to clipboard");
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const linked = preview?.connectLinked ?? false;
|
|
168
|
+
const empty =
|
|
169
|
+
kind === "chat" && !!preview && preview.items.length === dropped.length;
|
|
170
|
+
const summary = preview ? redactionSummary(preview.hits) : "";
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<DialogContent className="flex h-[100dvh] w-screen max-w-none flex-col gap-3 rounded-none p-4 sm:h-[90vh] sm:max-w-5xl sm:rounded-lg">
|
|
174
|
+
<div>
|
|
175
|
+
<DialogTitle>
|
|
176
|
+
Share {kind === "chat" ? "this chat" : "this page"}
|
|
177
|
+
</DialogTitle>
|
|
178
|
+
<DialogDescription>
|
|
179
|
+
A read-only snapshot, with secrets, home paths and machine names
|
|
180
|
+
masked. Nothing is uploaded until you create the link.
|
|
181
|
+
</DialogDescription>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto md:flex-row md:overflow-hidden">
|
|
185
|
+
<div className="bg-background min-h-[45vh] flex-1 overflow-hidden rounded-lg border md:min-h-0">
|
|
186
|
+
{preview ? (
|
|
187
|
+
<iframe
|
|
188
|
+
title="Preview of what will be shared"
|
|
189
|
+
sandbox="allow-scripts"
|
|
190
|
+
referrerPolicy="no-referrer"
|
|
191
|
+
srcDoc={preview.html}
|
|
192
|
+
className="h-full min-h-[45vh] w-full md:min-h-0"
|
|
193
|
+
/>
|
|
194
|
+
) : (
|
|
195
|
+
<p className="text-muted-foreground p-4 text-sm">
|
|
196
|
+
{error ?? "Preparing the preview…"}
|
|
197
|
+
</p>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<div className="flex shrink-0 flex-col gap-4 md:w-80 md:overflow-y-auto">
|
|
202
|
+
{kind === "chat" && (
|
|
203
|
+
<>
|
|
204
|
+
<label className="flex min-h-11 items-center justify-between gap-3 text-sm">
|
|
205
|
+
<span>Include tool calls and thinking</span>
|
|
206
|
+
<Switch checked={tools} onCheckedChange={setTools} />
|
|
207
|
+
</label>
|
|
208
|
+
<div className="min-h-0">
|
|
209
|
+
<div className="text-muted-foreground mb-1 text-xs font-medium">
|
|
210
|
+
Messages
|
|
211
|
+
</div>
|
|
212
|
+
<ul className="max-h-64 divide-y overflow-y-auto rounded-lg border">
|
|
213
|
+
{preview?.items.map((item) => (
|
|
214
|
+
<li key={item.id}>
|
|
215
|
+
<label className="flex min-h-11 cursor-pointer items-center gap-3 px-3 py-1.5 text-sm">
|
|
216
|
+
<input
|
|
217
|
+
type="checkbox"
|
|
218
|
+
className="h-4 w-4 shrink-0"
|
|
219
|
+
checked={!dropped.includes(item.id)}
|
|
220
|
+
onChange={() => toggle(item.id)}
|
|
221
|
+
/>
|
|
222
|
+
<span className="text-muted-foreground w-12 shrink-0 text-xs">
|
|
223
|
+
{TYPE_LABEL[item.type] ?? item.type}
|
|
224
|
+
</span>
|
|
225
|
+
<span className="min-w-0 flex-1 truncate">
|
|
226
|
+
{item.excerpt || "(empty)"}
|
|
227
|
+
</span>
|
|
228
|
+
</label>
|
|
229
|
+
</li>
|
|
230
|
+
))}
|
|
231
|
+
</ul>
|
|
232
|
+
</div>
|
|
233
|
+
</>
|
|
234
|
+
)}
|
|
235
|
+
|
|
236
|
+
{linked && (
|
|
237
|
+
<label className="flex min-h-11 items-center justify-between gap-3 text-sm">
|
|
238
|
+
<span>Link expires after</span>
|
|
239
|
+
<Select
|
|
240
|
+
value={expiresIn}
|
|
241
|
+
onValueChange={(v) => setExpiresIn(v as ShareExpiry)}
|
|
242
|
+
>
|
|
243
|
+
<SelectTrigger className="w-32">
|
|
244
|
+
<SelectValue />
|
|
245
|
+
</SelectTrigger>
|
|
246
|
+
<SelectContent>
|
|
247
|
+
{EXPIRIES.map((e) => (
|
|
248
|
+
<SelectItem key={e.value} value={e.value}>
|
|
249
|
+
{e.label}
|
|
250
|
+
</SelectItem>
|
|
251
|
+
))}
|
|
252
|
+
</SelectContent>
|
|
253
|
+
</Select>
|
|
254
|
+
</label>
|
|
255
|
+
)}
|
|
256
|
+
|
|
257
|
+
{preview && (
|
|
258
|
+
<div className="text-muted-foreground space-y-1 text-xs">
|
|
259
|
+
<p>{summary || "Nothing needed masking."}</p>
|
|
260
|
+
<p>
|
|
261
|
+
Your own machine and network names are masked; public sites like
|
|
262
|
+
github.com are not.
|
|
263
|
+
</p>
|
|
264
|
+
<p>{formatBytes(preview.bytes)}</p>
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
{preview?.tooBig && (
|
|
268
|
+
<p className="text-destructive text-sm">
|
|
269
|
+
Too big to share; drop some messages or turn tool calls off.
|
|
270
|
+
</p>
|
|
271
|
+
)}
|
|
272
|
+
{error && preview && (
|
|
273
|
+
<p className="text-destructive text-sm">{error}</p>
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
{created ? (
|
|
279
|
+
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
280
|
+
<input
|
|
281
|
+
readOnly
|
|
282
|
+
value={created}
|
|
283
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
284
|
+
aria-label="Shared link"
|
|
285
|
+
className="bg-muted min-h-11 min-w-0 flex-1 rounded-lg px-3 font-mono text-xs"
|
|
286
|
+
/>
|
|
287
|
+
<Button variant="outline" className="min-h-11" onClick={copyAgain}>
|
|
288
|
+
{copied ? (
|
|
289
|
+
<Check className="h-4 w-4" />
|
|
290
|
+
) : (
|
|
291
|
+
<Copy className="h-4 w-4" />
|
|
292
|
+
)}
|
|
293
|
+
Copy
|
|
294
|
+
</Button>
|
|
295
|
+
<Button variant="outline" className="min-h-11" asChild>
|
|
296
|
+
<a href={created} target="_blank" rel="noopener noreferrer">
|
|
297
|
+
<ExternalLink className="h-4 w-4" />
|
|
298
|
+
Open
|
|
299
|
+
</a>
|
|
300
|
+
</Button>
|
|
301
|
+
<Button className="min-h-11" onClick={shareUiActions.close}>
|
|
302
|
+
Done
|
|
303
|
+
</Button>
|
|
304
|
+
</div>
|
|
305
|
+
) : (
|
|
306
|
+
<div className="flex flex-col-reverse items-stretch gap-2 sm:flex-row sm:items-center sm:justify-end">
|
|
307
|
+
{preview && !linked && (
|
|
308
|
+
<p className="text-muted-foreground flex-1 text-xs">
|
|
309
|
+
Link this machine in Settings → Devices & access to share a
|
|
310
|
+
link.
|
|
311
|
+
</p>
|
|
312
|
+
)}
|
|
313
|
+
<Button
|
|
314
|
+
variant="outline"
|
|
315
|
+
className="min-h-11"
|
|
316
|
+
disabled={!preview || preview.tooBig || empty}
|
|
317
|
+
onClick={download}
|
|
318
|
+
>
|
|
319
|
+
<Download className="h-4 w-4" />
|
|
320
|
+
Download HTML
|
|
321
|
+
</Button>
|
|
322
|
+
{linked && (
|
|
323
|
+
<Button
|
|
324
|
+
className="min-h-11"
|
|
325
|
+
disabled={!preview || preview.tooBig || empty || create.isPending}
|
|
326
|
+
onClick={makeLink}
|
|
327
|
+
>
|
|
328
|
+
<Link2 className="h-4 w-4" />
|
|
329
|
+
{create.isPending ? "Creating…" : "Create link"}
|
|
330
|
+
</Button>
|
|
331
|
+
)}
|
|
332
|
+
</div>
|
|
333
|
+
)}
|
|
334
|
+
</DialogContent>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { fileName, formatBytes, redactionSummary } from "./format";
|
|
3
|
+
|
|
4
|
+
describe("share format", () => {
|
|
5
|
+
it("summarises what was masked, singular and plural", () => {
|
|
6
|
+
const none = { secret: 0, path: 0, ip: 0, host: 0, email: 0 };
|
|
7
|
+
expect(redactionSummary(none)).toBe("");
|
|
8
|
+
expect(redactionSummary({ ...none, secret: 1, path: 12 })).toBe(
|
|
9
|
+
"Masked: 1 secret, 12 paths"
|
|
10
|
+
);
|
|
11
|
+
});
|
|
12
|
+
it("names the download after the title", () => {
|
|
13
|
+
expect(fileName("My chat: part 2!")).toBe("my-chat-part-2.html");
|
|
14
|
+
expect(fileName("???")).toBe("shared.html");
|
|
15
|
+
});
|
|
16
|
+
it("sizes", () => {
|
|
17
|
+
expect(formatBytes(500)).toBe("500 bytes");
|
|
18
|
+
expect(formatBytes(2048)).toBe("2.0 KB");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { SharePreview } from "@/data/shares";
|
|
2
|
+
|
|
3
|
+
const PLURAL: Record<keyof SharePreview["hits"], [string, string]> = {
|
|
4
|
+
secret: ["secret", "secrets"],
|
|
5
|
+
path: ["path", "paths"],
|
|
6
|
+
ip: ["address", "addresses"],
|
|
7
|
+
host: ["host", "hosts"],
|
|
8
|
+
email: ["email", "emails"],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** "Masked: 3 secrets, 12 paths", or "" when nothing was. */
|
|
12
|
+
export function redactionSummary(hits: SharePreview["hits"]): string {
|
|
13
|
+
const parts = (Object.keys(PLURAL) as (keyof typeof PLURAL)[])
|
|
14
|
+
.filter((k) => hits[k] > 0)
|
|
15
|
+
.map((k) => `${hits[k]} ${PLURAL[k][hits[k] === 1 ? 0 : 1]}`);
|
|
16
|
+
return parts.length ? `Masked: ${parts.join(", ")}` : "";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatBytes(n: number): string {
|
|
20
|
+
if (n < 1024) return `${n} bytes`;
|
|
21
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
22
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A file name for a download: the title, plain, with .html. */
|
|
26
|
+
export function fileName(title: string): string {
|
|
27
|
+
const slug = title
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
30
|
+
.replace(/^-+|-+$/g, "")
|
|
31
|
+
.slice(0, 60);
|
|
32
|
+
return `${slug || "shared"}.html`;
|
|
33
|
+
}
|