@saadnvd1/agent-os 0.3.775 → 0.3.777
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/app/api/live-shares/[id]/route.ts +72 -0
- package/app/api/live-shares/route.test.ts +45 -0
- package/app/api/live-shares/route.ts +40 -0
- package/app/page.tsx +5 -0
- package/components/Chat/ChatPanel.tsx +6 -0
- package/components/Chat/EventRow.tsx +6 -0
- package/components/Live/GuestPromptCard.tsx +64 -0
- package/components/Live/InviteDialog.tsx +225 -0
- package/components/Live/LiveChip.tsx +109 -0
- package/components/Live/LivePanel.tsx +81 -0
- package/components/Live/LobbyCard.tsx +64 -0
- package/components/Pane/MobileTabBar.tsx +8 -0
- package/components/Pane/PaneMenu.tsx +10 -0
- package/components/SessionList/SessionRowMenu.tsx +6 -0
- package/components/Shares/SharedLinksPanel.tsx +51 -0
- package/data/live/index.ts +2 -0
- package/data/live/keys.ts +4 -0
- package/data/live/queries.ts +81 -0
- package/lib/chat/events.ts +4 -1
- package/lib/chat/registry.ts +10 -0
- package/lib/connect/control-api.ts +44 -0
- package/lib/db/migrations/20261029T1400_live_shares.ts +42 -0
- package/lib/live/clean.ts +54 -0
- package/lib/live/frames.ts +132 -0
- package/lib/live/inbox.ts +225 -0
- package/lib/live/publisher.ts +84 -0
- package/lib/live/ring.ts +61 -0
- package/lib/live/runtime.ts +51 -0
- package/lib/live/service.ts +318 -0
- package/lib/live/socket.ts +293 -0
- package/lib/live/store.ts +149 -0
- package/lib/live/types.ts +39 -0
- package/lib/share/connect.ts +24 -0
- package/lib/share/redact.ts +23 -1
- package/lib/share/secret-patterns.ts +77 -0
- package/lib/sidebar/row-menu.ts +2 -0
- package/package.json +1 -1
- package/server.ts +2 -0
- package/stores/liveUi.ts +17 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { fail, readJson, refuseDemo, toResponse } from "@/lib/share/route";
|
|
4
|
+
import {
|
|
5
|
+
admit,
|
|
6
|
+
deny,
|
|
7
|
+
dismissSuggestion,
|
|
8
|
+
endLive,
|
|
9
|
+
kick,
|
|
10
|
+
sendSuggestion,
|
|
11
|
+
startLive,
|
|
12
|
+
} from "@/lib/live/service";
|
|
13
|
+
|
|
14
|
+
const Action = z.discriminatedUnion("action", [
|
|
15
|
+
z.object({ action: z.literal("admit"), guestId: z.string().min(1).max(64) }),
|
|
16
|
+
z.object({ action: z.literal("deny"), guestId: z.string().min(1).max(64) }),
|
|
17
|
+
z.object({ action: z.literal("kick"), guestId: z.string().min(1).max(64) }),
|
|
18
|
+
z.object({
|
|
19
|
+
action: z.literal("send"),
|
|
20
|
+
suggestionId: z.string().min(1).max(64),
|
|
21
|
+
text: z.string().max(8000).optional(),
|
|
22
|
+
}),
|
|
23
|
+
z.object({
|
|
24
|
+
action: z.literal("dismiss"),
|
|
25
|
+
suggestionId: z.string().min(1).max(64),
|
|
26
|
+
}),
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
// POST /api/live-shares/:id - the host's decisions: let a guest in, turn one
|
|
30
|
+
// away, remove one, send a suggestion to the agent or dismiss it.
|
|
31
|
+
export async function POST(
|
|
32
|
+
request: Request,
|
|
33
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
34
|
+
) {
|
|
35
|
+
const refused = refuseDemo();
|
|
36
|
+
if (refused) return refused;
|
|
37
|
+
const { id } = await params;
|
|
38
|
+
const read = await readJson(request);
|
|
39
|
+
if ("refused" in read) return read.refused;
|
|
40
|
+
const body = Action.safeParse(read.body);
|
|
41
|
+
if (!body.success) return fail("Unknown action.", 400);
|
|
42
|
+
startLive();
|
|
43
|
+
try {
|
|
44
|
+
const a = body.data;
|
|
45
|
+
if (a.action === "admit") admit(id, a.guestId);
|
|
46
|
+
else if (a.action === "deny") deny(id, a.guestId);
|
|
47
|
+
else if (a.action === "kick") kick(id, a.guestId);
|
|
48
|
+
else if (a.action === "send")
|
|
49
|
+
await sendSuggestion(id, a.suggestionId, a.text);
|
|
50
|
+
else dismissSuggestion(id, a.suggestionId);
|
|
51
|
+
return NextResponse.json({ ok: true });
|
|
52
|
+
} catch (err) {
|
|
53
|
+
return toResponse(err);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// DELETE /api/live-shares/:id - end access for every guest.
|
|
58
|
+
export async function DELETE(
|
|
59
|
+
_request: Request,
|
|
60
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
61
|
+
) {
|
|
62
|
+
const refused = refuseDemo();
|
|
63
|
+
if (refused) return refused;
|
|
64
|
+
const { id } = await params;
|
|
65
|
+
startLive();
|
|
66
|
+
try {
|
|
67
|
+
await endLive(id);
|
|
68
|
+
return NextResponse.json({ ok: true });
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return toResponse(err);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { demoAllows } from "@/lib/security/demo";
|
|
3
|
+
import { POST } from "./route";
|
|
4
|
+
import { POST as act, DELETE } from "./[id]/route";
|
|
5
|
+
|
|
6
|
+
const req = (body: unknown, method = "POST") =>
|
|
7
|
+
new Request("http://localhost/api/live-shares", {
|
|
8
|
+
method,
|
|
9
|
+
headers: { "content-type": "application/json" },
|
|
10
|
+
body: JSON.stringify(body),
|
|
11
|
+
});
|
|
12
|
+
const ctx = { params: Promise.resolve({ id: "abc" }) };
|
|
13
|
+
|
|
14
|
+
describe("live-shares routes", () => {
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
delete process.env.AGENTOS_DEMO;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("are refused in demo mode, every one that changes state", async () => {
|
|
20
|
+
process.env.AGENTOS_DEMO = "1";
|
|
21
|
+
expect((await POST(req({ sessionId: "s" }))).status).toBe(403);
|
|
22
|
+
expect(
|
|
23
|
+
(await act(req({ action: "admit", guestId: "g" }), ctx)).status
|
|
24
|
+
).toBe(403);
|
|
25
|
+
expect((await DELETE(req({}, "DELETE"), ctx)).status).toBe(403);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("the demo gate lets none of them through", () => {
|
|
29
|
+
const home = "/srv/demo/home";
|
|
30
|
+
for (const [method, url] of [
|
|
31
|
+
["POST", "/api/live-shares"],
|
|
32
|
+
["GET", "/api/live-shares"],
|
|
33
|
+
["POST", "/api/live-shares/abc"],
|
|
34
|
+
["DELETE", "/api/live-shares/abc"],
|
|
35
|
+
])
|
|
36
|
+
expect(demoAllows({ method, url }, home)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("reject a bad body or an unknown action", async () => {
|
|
40
|
+
expect((await POST(req({ nope: 1 }))).status).toBe(400);
|
|
41
|
+
expect((await act(req({ action: "run", text: "rm" }), ctx)).status).toBe(
|
|
42
|
+
400
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { linked, NOT_LINKED } from "@/lib/share/connect";
|
|
4
|
+
import { fail, readJson, refuseDemo, toResponse } from "@/lib/share/route";
|
|
5
|
+
import { createLive, listLive, startLive } from "@/lib/live/service";
|
|
6
|
+
|
|
7
|
+
const Create = z.object({
|
|
8
|
+
sessionId: z.string().min(1).max(100),
|
|
9
|
+
expiresIn: z.enum(["1h", "session"]).default("1h"),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// POST /api/live-shares - mint a live link for one chat and start publishing.
|
|
13
|
+
export async function POST(request: Request) {
|
|
14
|
+
const refused = refuseDemo();
|
|
15
|
+
if (refused) return refused;
|
|
16
|
+
const read = await readJson(request);
|
|
17
|
+
if ("refused" in read) return read.refused;
|
|
18
|
+
const body = Create.safeParse(read.body);
|
|
19
|
+
if (!body.success) return fail("sessionId is required.", 400);
|
|
20
|
+
if (!linked()) return fail(NOT_LINKED, 409);
|
|
21
|
+
try {
|
|
22
|
+
const view = await createLive(body.data.sessionId, body.data.expiresIn);
|
|
23
|
+
return NextResponse.json(view, { status: 201 });
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if ((err as Error).message === "Session not found")
|
|
26
|
+
return fail("Session not found.", 404);
|
|
27
|
+
return toResponse(err);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// GET /api/live-shares?sessionId= - live links this machine holds, with who
|
|
32
|
+
// is waiting, who is watching and the open suggestions.
|
|
33
|
+
export async function GET(request: Request) {
|
|
34
|
+
const refused = refuseDemo();
|
|
35
|
+
if (refused) return refused;
|
|
36
|
+
startLive();
|
|
37
|
+
const sessionId =
|
|
38
|
+
new URL(request.url).searchParams.get("sessionId") ?? undefined;
|
|
39
|
+
return NextResponse.json({ linked: linked(), shares: listLive(sessionId) });
|
|
40
|
+
}
|
package/app/page.tsx
CHANGED
|
@@ -134,6 +134,10 @@ const MoveDialog = dynamic(
|
|
|
134
134
|
() => import("@/components/Tasks/MoveDialog").then((m) => m.MoveDialog),
|
|
135
135
|
{ ssr: false }
|
|
136
136
|
);
|
|
137
|
+
const InviteDialog = dynamic(
|
|
138
|
+
() => import("@/components/Live/InviteDialog").then((m) => m.InviteDialog),
|
|
139
|
+
{ ssr: false }
|
|
140
|
+
);
|
|
137
141
|
const ShareDialog = dynamic(
|
|
138
142
|
() => import("@/components/Share/ShareDialog").then((m) => m.ShareDialog),
|
|
139
143
|
{ ssr: false }
|
|
@@ -177,6 +181,7 @@ const AppDialogs = memo(function AppDialogs({
|
|
|
177
181
|
<ClaimsSheet />
|
|
178
182
|
<MoveDialog />
|
|
179
183
|
<ShareDialog />
|
|
184
|
+
<InviteDialog />
|
|
180
185
|
<ExportSheet />
|
|
181
186
|
<StartAnalystSheet />
|
|
182
187
|
<AwayReport />
|
|
@@ -40,6 +40,7 @@ import { SubagentCard } from "./Subagent";
|
|
|
40
40
|
import { UndoDialog, UndoneBlock } from "./Undo";
|
|
41
41
|
import { Composer } from "./Composer";
|
|
42
42
|
import { Queue } from "./Queue";
|
|
43
|
+
import { LivePanel } from "@/components/Live/LivePanel";
|
|
43
44
|
import { StaleChip } from "./StaleChip";
|
|
44
45
|
import { LearningCardsHost } from "@/components/LearningCards/LearningCard";
|
|
45
46
|
import { composerCompacts } from "@/lib/chat/stale";
|
|
@@ -834,6 +835,11 @@ export function ChatPanel({
|
|
|
834
835
|
onAsIs={setAsIs}
|
|
835
836
|
/>
|
|
836
837
|
<LearningCardsHost sessionId={sessionId} />
|
|
838
|
+
<LivePanel
|
|
839
|
+
sessionId={sessionId}
|
|
840
|
+
title={sessionName}
|
|
841
|
+
onEdit={(text) => setPrefill({ text, at: Date.now() })}
|
|
842
|
+
/>
|
|
837
843
|
<Composer
|
|
838
844
|
draftKey={sessionId}
|
|
839
845
|
attachScope={sessionId}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
Info,
|
|
8
8
|
Smartphone,
|
|
9
9
|
UserCheck,
|
|
10
|
+
Users,
|
|
10
11
|
Zap,
|
|
11
12
|
type LucideIcon,
|
|
12
13
|
} from "lucide-react";
|
|
@@ -27,6 +28,7 @@ export const ICON: Record<ChatOriginKind, LucideIcon> = {
|
|
|
27
28
|
system: Info,
|
|
28
29
|
decision: UserCheck,
|
|
29
30
|
device: Smartphone,
|
|
31
|
+
guest: Users,
|
|
30
32
|
};
|
|
31
33
|
|
|
32
34
|
// Longer than this (what fits beside a source on a phone), or more than one line,
|
|
@@ -177,6 +179,10 @@ export function EventRow({
|
|
|
177
179
|
head={
|
|
178
180
|
decision ? (
|
|
179
181
|
<span className="truncate font-medium">Saad decided</span>
|
|
182
|
+
) : origin.kind === "guest" ? (
|
|
183
|
+
<span className="truncate font-medium">
|
|
184
|
+
Suggested by {origin.label}
|
|
185
|
+
</span>
|
|
180
186
|
) : (
|
|
181
187
|
<Source origin={origin} />
|
|
182
188
|
)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Pencil, Send, X } from "lucide-react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import type { LiveView } from "@/data/live";
|
|
6
|
+
|
|
7
|
+
type Suggestion = LiveView["suggestions"][number];
|
|
8
|
+
|
|
9
|
+
// A guest's suggested prompt, as plain text. Nothing is sent until Send.
|
|
10
|
+
export function GuestPromptCard({
|
|
11
|
+
suggestion,
|
|
12
|
+
busy,
|
|
13
|
+
onSend,
|
|
14
|
+
onEdit,
|
|
15
|
+
onDismiss,
|
|
16
|
+
}: {
|
|
17
|
+
suggestion: Suggestion;
|
|
18
|
+
busy: boolean;
|
|
19
|
+
onSend: () => void;
|
|
20
|
+
onEdit: () => void;
|
|
21
|
+
onDismiss: () => void;
|
|
22
|
+
}) {
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
role="group"
|
|
26
|
+
aria-label={`Suggestion from ${suggestion.guestName}`}
|
|
27
|
+
className="bg-muted/60 flex flex-col gap-2 rounded-xl p-3 shadow-sm"
|
|
28
|
+
>
|
|
29
|
+
<p className="text-muted-foreground text-xs">
|
|
30
|
+
Suggestion from{" "}
|
|
31
|
+
<span className="text-foreground font-medium">
|
|
32
|
+
{suggestion.guestName}
|
|
33
|
+
</span>
|
|
34
|
+
</p>
|
|
35
|
+
<p className="max-h-40 overflow-y-auto text-sm [overflow-wrap:anywhere] whitespace-pre-wrap">
|
|
36
|
+
{suggestion.text}
|
|
37
|
+
</p>
|
|
38
|
+
<div className="flex gap-2">
|
|
39
|
+
<Button className="min-h-11 flex-1" disabled={busy} onClick={onSend}>
|
|
40
|
+
<Send className="h-4 w-4" />
|
|
41
|
+
Send
|
|
42
|
+
</Button>
|
|
43
|
+
<Button
|
|
44
|
+
variant="outline"
|
|
45
|
+
className="min-h-11 flex-1"
|
|
46
|
+
disabled={busy}
|
|
47
|
+
onClick={onEdit}
|
|
48
|
+
>
|
|
49
|
+
<Pencil className="h-4 w-4" />
|
|
50
|
+
Edit
|
|
51
|
+
</Button>
|
|
52
|
+
<Button
|
|
53
|
+
variant="outline"
|
|
54
|
+
className="min-h-11 flex-1"
|
|
55
|
+
disabled={busy}
|
|
56
|
+
onClick={onDismiss}
|
|
57
|
+
>
|
|
58
|
+
<X className="h-4 w-4" />
|
|
59
|
+
Dismiss
|
|
60
|
+
</Button>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useSnapshot } from "valtio";
|
|
5
|
+
import { Check, Copy, ExternalLink, Share, Users } from "lucide-react";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogDescription,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
} from "@/components/ui/dialog";
|
|
13
|
+
import { Notice } from "@/components/ui/notice";
|
|
14
|
+
import { Segmented } from "@/components/ui/segmented";
|
|
15
|
+
import { ShimmeringLoader as Skeleton } from "@/components/ui/skeleton";
|
|
16
|
+
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
|
|
17
|
+
import {
|
|
18
|
+
useCreateLive,
|
|
19
|
+
useEndLive,
|
|
20
|
+
useLiveShare,
|
|
21
|
+
type LiveExpiry,
|
|
22
|
+
type LiveView,
|
|
23
|
+
} from "@/data/live";
|
|
24
|
+
import { EXPIRY_LABEL } from "@/lib/live/types";
|
|
25
|
+
import { liveUi, liveUiActions } from "@/stores/liveUi";
|
|
26
|
+
|
|
27
|
+
// "Invite to watch live" on a chat: a link for one guest-facing page, with
|
|
28
|
+
// the host deciding who comes in and what reaches the agent.
|
|
29
|
+
export function InviteDialog() {
|
|
30
|
+
const { target } = useSnapshot(liveUi);
|
|
31
|
+
return (
|
|
32
|
+
<Dialog
|
|
33
|
+
open={!!target}
|
|
34
|
+
onOpenChange={(open) => !open && liveUiActions.close()}
|
|
35
|
+
>
|
|
36
|
+
{target && <InviteBody sessionId={target.sessionId} />}
|
|
37
|
+
</Dialog>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function InviteBody({ sessionId }: { sessionId: string }) {
|
|
42
|
+
const query = useLiveShare(sessionId);
|
|
43
|
+
const share = query.data?.share;
|
|
44
|
+
return (
|
|
45
|
+
<DialogContent className="flex max-h-[100dvh] w-screen max-w-none flex-col gap-4 overflow-y-auto rounded-none p-4 sm:max-w-md sm:rounded-lg">
|
|
46
|
+
<div>
|
|
47
|
+
<DialogTitle>Invite to watch live</DialogTitle>
|
|
48
|
+
<DialogDescription>
|
|
49
|
+
A guest opens the link, types a name and waits for you to let them in.
|
|
50
|
+
They see this chat as it happens, with secrets masked, and can suggest
|
|
51
|
+
a prompt. Nothing they type reaches the agent unless you tap Send.
|
|
52
|
+
</DialogDescription>
|
|
53
|
+
</div>
|
|
54
|
+
{query.isPending ? (
|
|
55
|
+
<Skeleton className="h-24 w-full" />
|
|
56
|
+
) : !query.data?.linked ? (
|
|
57
|
+
<Notice tone="warning">
|
|
58
|
+
Link this machine in Settings → Devices & access to invite people.
|
|
59
|
+
</Notice>
|
|
60
|
+
) : share ? (
|
|
61
|
+
<Link share={share} />
|
|
62
|
+
) : (
|
|
63
|
+
<Create sessionId={sessionId} />
|
|
64
|
+
)}
|
|
65
|
+
</DialogContent>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function Create({ sessionId }: { sessionId: string }) {
|
|
70
|
+
const [expiresIn, setExpiresIn] = useState<LiveExpiry>("1h");
|
|
71
|
+
const create = useCreateLive();
|
|
72
|
+
return (
|
|
73
|
+
<>
|
|
74
|
+
<div className="flex flex-col gap-1.5">
|
|
75
|
+
<span className="text-muted-foreground text-xs font-medium">
|
|
76
|
+
What guests can do
|
|
77
|
+
</span>
|
|
78
|
+
<Segmented
|
|
79
|
+
label="Access"
|
|
80
|
+
value="watch"
|
|
81
|
+
onChange={() => {}}
|
|
82
|
+
options={[
|
|
83
|
+
{ value: "watch", label: "Watch + suggest" },
|
|
84
|
+
{ value: "drive", label: "Drive (later)" },
|
|
85
|
+
]}
|
|
86
|
+
disabled
|
|
87
|
+
/>
|
|
88
|
+
<p className="text-muted-foreground text-xs">
|
|
89
|
+
Driving the agent from a guest link isn't available yet.
|
|
90
|
+
</p>
|
|
91
|
+
</div>
|
|
92
|
+
<div className="flex flex-col gap-1.5">
|
|
93
|
+
<span className="text-muted-foreground text-xs font-medium">
|
|
94
|
+
The link works for
|
|
95
|
+
</span>
|
|
96
|
+
<Segmented
|
|
97
|
+
label="Expiry"
|
|
98
|
+
value={expiresIn}
|
|
99
|
+
onChange={setExpiresIn}
|
|
100
|
+
options={(["1h", "session"] as const).map((v) => ({
|
|
101
|
+
value: v,
|
|
102
|
+
label: EXPIRY_LABEL[v],
|
|
103
|
+
}))}
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
{create.error && (
|
|
107
|
+
<p className="text-destructive text-sm">{create.error.message}</p>
|
|
108
|
+
)}
|
|
109
|
+
<Button
|
|
110
|
+
className="min-h-11"
|
|
111
|
+
disabled={create.isPending}
|
|
112
|
+
onClick={() => create.mutate({ sessionId, expiresIn })}
|
|
113
|
+
>
|
|
114
|
+
<Users className="h-4 w-4" />
|
|
115
|
+
{create.isPending ? "Creating…" : "Create invite link"}
|
|
116
|
+
</Button>
|
|
117
|
+
</>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function Link({ share }: { share: LiveView }) {
|
|
122
|
+
const { copied, copy } = useCopyToClipboard();
|
|
123
|
+
const end = useEndLive(share.id);
|
|
124
|
+
const qr = useQr(share.url);
|
|
125
|
+
const canShare = typeof navigator !== "undefined" && "share" in navigator;
|
|
126
|
+
return (
|
|
127
|
+
<>
|
|
128
|
+
<div className="flex flex-col items-center gap-3">
|
|
129
|
+
{qr ? (
|
|
130
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
131
|
+
<img
|
|
132
|
+
src={qr}
|
|
133
|
+
alt="QR code for the invite link"
|
|
134
|
+
className="size-40 rounded-lg bg-white p-2"
|
|
135
|
+
/>
|
|
136
|
+
) : (
|
|
137
|
+
<Skeleton className="size-40" />
|
|
138
|
+
)}
|
|
139
|
+
<input
|
|
140
|
+
readOnly
|
|
141
|
+
value={share.url}
|
|
142
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
143
|
+
aria-label="Invite link"
|
|
144
|
+
className="bg-muted min-h-11 w-full rounded-lg px-3 font-mono text-xs"
|
|
145
|
+
/>
|
|
146
|
+
</div>
|
|
147
|
+
<div className="flex flex-wrap gap-2">
|
|
148
|
+
<Button
|
|
149
|
+
variant="outline"
|
|
150
|
+
className="min-h-11 flex-1"
|
|
151
|
+
onClick={() => copy(share.url)}
|
|
152
|
+
>
|
|
153
|
+
{copied ? (
|
|
154
|
+
<Check className="h-4 w-4" />
|
|
155
|
+
) : (
|
|
156
|
+
<Copy className="h-4 w-4" />
|
|
157
|
+
)}
|
|
158
|
+
Copy
|
|
159
|
+
</Button>
|
|
160
|
+
{canShare && (
|
|
161
|
+
<Button
|
|
162
|
+
variant="outline"
|
|
163
|
+
className="min-h-11 flex-1"
|
|
164
|
+
onClick={() =>
|
|
165
|
+
navigator
|
|
166
|
+
.share({ title: share.title, url: share.url })
|
|
167
|
+
.catch(() => {})
|
|
168
|
+
}
|
|
169
|
+
>
|
|
170
|
+
<Share className="h-4 w-4" />
|
|
171
|
+
Share
|
|
172
|
+
</Button>
|
|
173
|
+
)}
|
|
174
|
+
<Button variant="outline" className="min-h-11 flex-1" asChild>
|
|
175
|
+
<a href={share.url} target="_blank" rel="noopener noreferrer">
|
|
176
|
+
<ExternalLink className="h-4 w-4" />
|
|
177
|
+
Open
|
|
178
|
+
</a>
|
|
179
|
+
</Button>
|
|
180
|
+
</div>
|
|
181
|
+
<p className="text-muted-foreground text-xs">
|
|
182
|
+
{share.endsWithSession
|
|
183
|
+
? "Works until you end access or the session ends."
|
|
184
|
+
: share.expiresAt
|
|
185
|
+
? `Works until ${new Date(share.expiresAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}.`
|
|
186
|
+
: ""}{" "}
|
|
187
|
+
Requests and suggestions show above the message box.
|
|
188
|
+
</p>
|
|
189
|
+
{end.error && (
|
|
190
|
+
<p className="text-destructive text-sm">{end.error.message}</p>
|
|
191
|
+
)}
|
|
192
|
+
<div className="flex gap-2">
|
|
193
|
+
<Button
|
|
194
|
+
variant="outline"
|
|
195
|
+
className="text-destructive min-h-11 flex-1"
|
|
196
|
+
disabled={end.isPending}
|
|
197
|
+
onClick={() => end.mutate()}
|
|
198
|
+
>
|
|
199
|
+
End access
|
|
200
|
+
</Button>
|
|
201
|
+
<Button className="min-h-11 flex-1" onClick={liveUiActions.close}>
|
|
202
|
+
Done
|
|
203
|
+
</Button>
|
|
204
|
+
</div>
|
|
205
|
+
</>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The code is drawn here, in the browser: the link goes to no outside service.
|
|
210
|
+
function useQr(url: string): string | null {
|
|
211
|
+
const [src, setSrc] = useState<string | null>(null);
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
let live = true;
|
|
214
|
+
import("qrcode")
|
|
215
|
+
.then((m) =>
|
|
216
|
+
m.toDataURL(url, { margin: 0, width: 256, errorCorrectionLevel: "M" })
|
|
217
|
+
)
|
|
218
|
+
.then((data) => live && setSrc(data))
|
|
219
|
+
.catch(() => {});
|
|
220
|
+
return () => {
|
|
221
|
+
live = false;
|
|
222
|
+
};
|
|
223
|
+
}, [url]);
|
|
224
|
+
return src;
|
|
225
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { ChevronDown, Radio, UserMinus } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { StatusPill } from "@/components/ui/status-pill";
|
|
7
|
+
import type { LiveView } from "@/data/live";
|
|
8
|
+
import { cn } from "@/lib/utils";
|
|
9
|
+
|
|
10
|
+
export function presenceText(guests: LiveView["guests"]): string {
|
|
11
|
+
const here = guests.filter((g) => g.status === "admitted" && g.online);
|
|
12
|
+
if (!here.length) return "No one watching yet";
|
|
13
|
+
if (here.length === 1) return `${here[0].name} is watching`;
|
|
14
|
+
return `${here[0].name} +${here.length - 1} watching`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The session's live state: who is watching, with Kick and End access.
|
|
18
|
+
export function LiveChip({
|
|
19
|
+
share,
|
|
20
|
+
busy,
|
|
21
|
+
onKick,
|
|
22
|
+
onEnd,
|
|
23
|
+
onInvite,
|
|
24
|
+
}: {
|
|
25
|
+
share: LiveView;
|
|
26
|
+
busy: boolean;
|
|
27
|
+
onKick: (guestId: string) => void;
|
|
28
|
+
onEnd: () => void;
|
|
29
|
+
onInvite: () => void;
|
|
30
|
+
}) {
|
|
31
|
+
const [open, setOpen] = useState(false);
|
|
32
|
+
const admitted = share.guests.filter((g) => g.status === "admitted");
|
|
33
|
+
return (
|
|
34
|
+
<div className="flex flex-col gap-1">
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
aria-expanded={open}
|
|
38
|
+
onClick={() => setOpen(!open)}
|
|
39
|
+
className="hover:bg-muted/60 flex min-h-11 items-center gap-2 rounded-lg px-2 text-left text-xs"
|
|
40
|
+
>
|
|
41
|
+
<StatusPill tone={share.connected ? "green" : "amber"}>
|
|
42
|
+
<Radio className="h-3 w-3" aria-hidden />
|
|
43
|
+
{share.connected ? "Live" : "Reconnecting"}
|
|
44
|
+
</StatusPill>
|
|
45
|
+
<span className="min-w-0 flex-1 truncate">
|
|
46
|
+
{presenceText(share.guests)}
|
|
47
|
+
</span>
|
|
48
|
+
<ChevronDown
|
|
49
|
+
className={cn(
|
|
50
|
+
"h-3.5 w-3.5 transition-transform",
|
|
51
|
+
open && "rotate-180"
|
|
52
|
+
)}
|
|
53
|
+
aria-hidden
|
|
54
|
+
/>
|
|
55
|
+
</button>
|
|
56
|
+
{open && (
|
|
57
|
+
<div className="bg-muted/60 flex flex-col gap-1 rounded-xl p-2 shadow-sm">
|
|
58
|
+
{share.notice && (
|
|
59
|
+
<p className="text-muted-foreground px-2 text-xs">{share.notice}</p>
|
|
60
|
+
)}
|
|
61
|
+
{admitted.length === 0 && (
|
|
62
|
+
<p className="text-muted-foreground px-2 py-1 text-xs">
|
|
63
|
+
No one has joined yet.
|
|
64
|
+
</p>
|
|
65
|
+
)}
|
|
66
|
+
{admitted.map((g) => (
|
|
67
|
+
<div key={g.id} className="flex min-h-11 items-center gap-2 px-2">
|
|
68
|
+
<span
|
|
69
|
+
className={cn(
|
|
70
|
+
"size-2 shrink-0 rounded-full",
|
|
71
|
+
g.online ? "bg-green-500" : "bg-muted-foreground/40"
|
|
72
|
+
)}
|
|
73
|
+
aria-hidden
|
|
74
|
+
/>
|
|
75
|
+
<span className="min-w-0 flex-1 truncate text-sm">{g.name}</span>
|
|
76
|
+
<Button
|
|
77
|
+
variant="ghost"
|
|
78
|
+
className="min-h-11"
|
|
79
|
+
disabled={busy}
|
|
80
|
+
onClick={() => onKick(g.id)}
|
|
81
|
+
aria-label={`Kick ${g.name}`}
|
|
82
|
+
>
|
|
83
|
+
<UserMinus className="h-4 w-4" />
|
|
84
|
+
Kick
|
|
85
|
+
</Button>
|
|
86
|
+
</div>
|
|
87
|
+
))}
|
|
88
|
+
<div className="flex gap-2 pt-1">
|
|
89
|
+
<Button
|
|
90
|
+
variant="outline"
|
|
91
|
+
className="min-h-11 flex-1"
|
|
92
|
+
onClick={onInvite}
|
|
93
|
+
>
|
|
94
|
+
Invite link
|
|
95
|
+
</Button>
|
|
96
|
+
<Button
|
|
97
|
+
variant="outline"
|
|
98
|
+
className="text-destructive min-h-11 flex-1"
|
|
99
|
+
disabled={busy}
|
|
100
|
+
onClick={onEnd}
|
|
101
|
+
>
|
|
102
|
+
End access
|
|
103
|
+
</Button>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|