@saadnvd1/agent-os 0.3.778 → 0.3.780
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 +21 -3
- package/app/api/live-shares/route.test.ts +36 -0
- package/app/api/live-shares/route.ts +14 -3
- package/components/Chat/Approval.tsx +13 -2
- package/components/Chat/EventRow.tsx +5 -0
- package/components/Chat/Queue.tsx +27 -15
- package/components/Live/DriveBanner.tsx +42 -0
- package/components/Live/InviteDialog.tsx +44 -13
- package/components/Live/LiveChip.tsx +34 -13
- package/components/Live/LivePanel.tsx +26 -9
- package/components/Live/LobbyCard.tsx +18 -21
- package/components/ui/segmented.tsx +2 -1
- package/data/live/queries.ts +25 -5
- package/lib/chat/driver.ts +4 -0
- package/lib/chat/drivers/claude-approvals.ts +93 -25
- package/lib/chat/drivers/claude.ts +2 -1
- package/lib/chat/events.ts +21 -1
- package/lib/chat/queued.ts +57 -10
- package/lib/chat/registry.ts +2 -0
- package/lib/chat/runner.ts +65 -2
- package/lib/chat/socket.ts +12 -9
- package/lib/chat/worker/gate-testing.ts +131 -0
- package/lib/chat/worker/guest-gate.ts +511 -0
- package/lib/chat/worker/host.ts +50 -6
- package/lib/chat/worker/main.ts +16 -2
- package/lib/chat/worker/protocol.ts +11 -0
- package/lib/connect/control-api.ts +6 -2
- package/lib/db/migrations/20261030T1200_live_share_level.ts +19 -0
- package/lib/db/migrations/20261030T1300_chat_guest_steers.ts +20 -0
- package/lib/db/migrations/20261030T1400_chat_guest_steers_ended.ts +14 -0
- package/lib/live/clean.ts +6 -0
- package/lib/live/drive.ts +398 -0
- package/lib/live/frames.ts +9 -2
- package/lib/live/inbox.ts +47 -1
- package/lib/live/levels.ts +74 -0
- package/lib/live/publisher.ts +10 -0
- package/lib/live/runtime.ts +20 -0
- package/lib/live/service.ts +133 -10
- package/lib/live/store.ts +54 -3
- package/lib/live/testing.ts +281 -0
- package/lib/live/types.ts +52 -0
- package/lib/share/connect.ts +1 -0
- package/package.json +1 -1
|
@@ -3,18 +3,33 @@ import { z } from "zod";
|
|
|
3
3
|
import { fail, readJson, refuseDemo, toResponse } from "@/lib/share/route";
|
|
4
4
|
import {
|
|
5
5
|
admit,
|
|
6
|
+
changeLevel,
|
|
6
7
|
deny,
|
|
7
8
|
dismissSuggestion,
|
|
8
9
|
endLive,
|
|
10
|
+
giveWheel,
|
|
9
11
|
kick,
|
|
10
12
|
sendSuggestion,
|
|
11
13
|
startLive,
|
|
12
14
|
} from "@/lib/live/service";
|
|
13
15
|
|
|
14
16
|
const Action = z.discriminatedUnion("action", [
|
|
15
|
-
z.object({
|
|
17
|
+
z.object({
|
|
18
|
+
action: z.literal("admit"),
|
|
19
|
+
guestId: z.string().min(1).max(64),
|
|
20
|
+
drive: z.boolean().optional(),
|
|
21
|
+
}),
|
|
16
22
|
z.object({ action: z.literal("deny"), guestId: z.string().min(1).max(64) }),
|
|
17
23
|
z.object({ action: z.literal("kick"), guestId: z.string().min(1).max(64) }),
|
|
24
|
+
z.object({
|
|
25
|
+
action: z.literal("level"),
|
|
26
|
+
level: z.enum(["watch", "suggest", "drive"]),
|
|
27
|
+
}),
|
|
28
|
+
// Gives the wheel to an admitted guest of a Drive share; null takes it back.
|
|
29
|
+
z.object({
|
|
30
|
+
action: z.literal("wheel"),
|
|
31
|
+
guestId: z.string().min(1).max(64).nullable(),
|
|
32
|
+
}),
|
|
18
33
|
z.object({
|
|
19
34
|
action: z.literal("send"),
|
|
20
35
|
suggestionId: z.string().min(1).max(64),
|
|
@@ -27,7 +42,8 @@ const Action = z.discriminatedUnion("action", [
|
|
|
27
42
|
]);
|
|
28
43
|
|
|
29
44
|
// 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
|
|
45
|
+
// away, remove one, send a suggestion to the agent or dismiss it, change the
|
|
46
|
+
// level, give or take back the wheel.
|
|
31
47
|
export async function POST(
|
|
32
48
|
request: Request,
|
|
33
49
|
{ params }: { params: Promise<{ id: string }> }
|
|
@@ -42,9 +58,11 @@ export async function POST(
|
|
|
42
58
|
startLive();
|
|
43
59
|
try {
|
|
44
60
|
const a = body.data;
|
|
45
|
-
if (a.action === "admit") admit(id, a.guestId);
|
|
61
|
+
if (a.action === "admit") admit(id, a.guestId, { drive: a.drive });
|
|
46
62
|
else if (a.action === "deny") deny(id, a.guestId);
|
|
47
63
|
else if (a.action === "kick") kick(id, a.guestId);
|
|
64
|
+
else if (a.action === "level") changeLevel(id, a.level);
|
|
65
|
+
else if (a.action === "wheel") giveWheel(id, a.guestId);
|
|
48
66
|
else if (a.action === "send")
|
|
49
67
|
await sendSuggestion(id, a.suggestionId, a.text);
|
|
50
68
|
else dismissSuggestion(id, a.suggestionId);
|
|
@@ -42,4 +42,40 @@ describe("live-shares routes", () => {
|
|
|
42
42
|
400
|
|
43
43
|
);
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
it("accept watch, suggest and drive, and history as a boolean", async () => {
|
|
47
|
+
// Past validation: not linked to Connect here, so 409, never 400.
|
|
48
|
+
expect((await POST(req({ sessionId: "s", level: "drive" }))).status).toBe(
|
|
49
|
+
409
|
|
50
|
+
);
|
|
51
|
+
expect((await POST(req({ sessionId: "s", level: "root" }))).status).toBe(
|
|
52
|
+
400
|
|
53
|
+
);
|
|
54
|
+
expect((await POST(req({ sessionId: "s", history: "yes" }))).status).toBe(
|
|
55
|
+
400
|
|
56
|
+
);
|
|
57
|
+
// A level for a share this machine doesn't hold: 404, so it validated.
|
|
58
|
+
expect(
|
|
59
|
+
(await act(req({ action: "level", level: "drive" }), ctx)).status
|
|
60
|
+
).toBe(404);
|
|
61
|
+
expect(
|
|
62
|
+
(await act(req({ action: "level", level: "admin" }), ctx)).status
|
|
63
|
+
).toBe(400);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("validate the wheel and admit-to-drive actions", async () => {
|
|
67
|
+
for (const body of [
|
|
68
|
+
{ action: "wheel" },
|
|
69
|
+
{ action: "wheel", guestId: 7 },
|
|
70
|
+
{ action: "wheel", guestId: "" },
|
|
71
|
+
{ action: "admit", guestId: "g", drive: "yes" },
|
|
72
|
+
])
|
|
73
|
+
expect((await act(req(body), ctx)).status).toBe(400);
|
|
74
|
+
for (const body of [
|
|
75
|
+
{ action: "wheel", guestId: "g1" },
|
|
76
|
+
{ action: "wheel", guestId: null },
|
|
77
|
+
{ action: "admit", guestId: "g", drive: true },
|
|
78
|
+
])
|
|
79
|
+
expect((await act(req(body), ctx)).status).toBe(404);
|
|
80
|
+
});
|
|
45
81
|
});
|
|
@@ -2,11 +2,13 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { linked, NOT_LINKED } from "@/lib/share/connect";
|
|
4
4
|
import { fail, readJson, refuseDemo, toResponse } from "@/lib/share/route";
|
|
5
|
-
import { createLive, listLive, startLive } from "@/lib/live/service";
|
|
5
|
+
import { createLive, inviteFor, listLive, startLive } from "@/lib/live/service";
|
|
6
6
|
|
|
7
7
|
const Create = z.object({
|
|
8
8
|
sessionId: z.string().min(1).max(100),
|
|
9
9
|
expiresIn: z.enum(["1h", "session"]).default("1h"),
|
|
10
|
+
level: z.enum(["watch", "suggest", "drive"]).default("suggest"),
|
|
11
|
+
history: z.boolean().default(true),
|
|
10
12
|
});
|
|
11
13
|
|
|
12
14
|
// POST /api/live-shares - mint a live link for one chat and start publishing.
|
|
@@ -19,7 +21,12 @@ export async function POST(request: Request) {
|
|
|
19
21
|
if (!body.success) return fail("sessionId is required.", 400);
|
|
20
22
|
if (!linked()) return fail(NOT_LINKED, 409);
|
|
21
23
|
try {
|
|
22
|
-
const view = await createLive(
|
|
24
|
+
const view = await createLive(
|
|
25
|
+
body.data.sessionId,
|
|
26
|
+
body.data.expiresIn,
|
|
27
|
+
body.data.level,
|
|
28
|
+
body.data.history
|
|
29
|
+
);
|
|
23
30
|
return NextResponse.json(view, { status: 201 });
|
|
24
31
|
} catch (err) {
|
|
25
32
|
if ((err as Error).message === "Session not found")
|
|
@@ -36,5 +43,9 @@ export async function GET(request: Request) {
|
|
|
36
43
|
startLive();
|
|
37
44
|
const sessionId =
|
|
38
45
|
new URL(request.url).searchParams.get("sessionId") ?? undefined;
|
|
39
|
-
return NextResponse.json({
|
|
46
|
+
return NextResponse.json({
|
|
47
|
+
linked: linked(),
|
|
48
|
+
shares: listLive(sessionId),
|
|
49
|
+
...(sessionId ? { invite: inviteFor(sessionId) } : {}),
|
|
50
|
+
});
|
|
40
51
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useState } from "react";
|
|
4
|
-
import { MessageCircleQuestion, ShieldQuestion } from "lucide-react";
|
|
4
|
+
import { MessageCircleQuestion, ShieldQuestion, Users } from "lucide-react";
|
|
5
5
|
import { Button } from "@/components/ui/button";
|
|
6
6
|
import type { ApprovalDecision, ChatItem } from "@/lib/chat/events";
|
|
7
7
|
import { cn } from "@/lib/utils";
|
|
@@ -20,6 +20,17 @@ function AccessCard({
|
|
|
20
20
|
}) {
|
|
21
21
|
return (
|
|
22
22
|
<div className="bg-primary/[0.06] space-y-3 rounded-xl p-3">
|
|
23
|
+
{item.guest && (
|
|
24
|
+
<p className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
|
25
|
+
<Users className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
|
26
|
+
<span className="min-w-0 truncate">
|
|
27
|
+
<span className="text-foreground font-medium">
|
|
28
|
+
{item.guest.name}'s turn
|
|
29
|
+
</span>{" "}
|
|
30
|
+
· a live guest asked for this
|
|
31
|
+
</span>
|
|
32
|
+
</p>
|
|
33
|
+
)}
|
|
23
34
|
<div className="flex items-start gap-2 text-sm">
|
|
24
35
|
<ShieldQuestion className="text-primary mt-0.5 h-4 w-4 shrink-0" />
|
|
25
36
|
<span className="min-w-0 break-words">{item.title}</span>
|
|
@@ -33,7 +44,7 @@ function AccessCard({
|
|
|
33
44
|
>
|
|
34
45
|
Deny
|
|
35
46
|
</Button>
|
|
36
|
-
{item.canAlways && (
|
|
47
|
+
{item.canAlways && !item.guest && (
|
|
37
48
|
<Button
|
|
38
49
|
variant="secondary"
|
|
39
50
|
className="h-11 md:h-9"
|
|
@@ -29,6 +29,7 @@ export const ICON: Record<ChatOriginKind, LucideIcon> = {
|
|
|
29
29
|
decision: UserCheck,
|
|
30
30
|
device: Smartphone,
|
|
31
31
|
guest: Users,
|
|
32
|
+
"guest-drive": Users,
|
|
32
33
|
};
|
|
33
34
|
|
|
34
35
|
// Longer than this (what fits beside a source on a phone), or more than one line,
|
|
@@ -183,6 +184,10 @@ export function EventRow({
|
|
|
183
184
|
<span className="truncate font-medium">
|
|
184
185
|
Suggested by {origin.label}
|
|
185
186
|
</span>
|
|
187
|
+
) : origin.kind === "guest-drive" ? (
|
|
188
|
+
<span className="truncate font-medium">
|
|
189
|
+
Prompted by {origin.label}
|
|
190
|
+
</span>
|
|
186
191
|
) : (
|
|
187
192
|
<Source origin={origin} />
|
|
188
193
|
)
|
|
@@ -53,7 +53,15 @@ export function Queue({
|
|
|
53
53
|
<span className="text-muted-foreground w-5 shrink-0 pt-3 text-center text-xs tabular-nums">
|
|
54
54
|
{i + 1}
|
|
55
55
|
</span>
|
|
56
|
-
{
|
|
56
|
+
{m.guest ? (
|
|
57
|
+
// A driving guest's prompt: theirs to word, the host's to remove.
|
|
58
|
+
<div className="min-h-11 min-w-0 flex-1 px-2 py-2.5 text-sm break-words whitespace-pre-wrap">
|
|
59
|
+
<span className="text-muted-foreground block text-xs">
|
|
60
|
+
{m.guest.name} (guest)
|
|
61
|
+
</span>
|
|
62
|
+
<span className="line-clamp-3">{m.guest.body}</span>
|
|
63
|
+
</div>
|
|
64
|
+
) : editing?.id === m.id ? (
|
|
57
65
|
<textarea
|
|
58
66
|
autoFocus
|
|
59
67
|
value={editing.text}
|
|
@@ -99,20 +107,24 @@ export function Queue({
|
|
|
99
107
|
</button>
|
|
100
108
|
)}
|
|
101
109
|
<div className="flex shrink-0 items-center">
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
110
|
+
{!m.guest && (
|
|
111
|
+
<>
|
|
112
|
+
<IconButton
|
|
113
|
+
label="Send now"
|
|
114
|
+
onClick={() => onSendNow(m.id)}
|
|
115
|
+
className="text-foreground"
|
|
116
|
+
>
|
|
117
|
+
<SendHorizontal className="h-4 w-4" />
|
|
118
|
+
</IconButton>
|
|
119
|
+
<IconButton
|
|
120
|
+
label="Edit"
|
|
121
|
+
onClick={() => setEditing({ id: m.id, text: m.text })}
|
|
122
|
+
className="max-md:hidden"
|
|
123
|
+
>
|
|
124
|
+
<Pencil className="h-3.5 w-3.5" />
|
|
125
|
+
</IconButton>
|
|
126
|
+
</>
|
|
127
|
+
)}
|
|
116
128
|
<IconButton
|
|
117
129
|
label="Move up"
|
|
118
130
|
disabled={i === 0}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Hand, Users } from "lucide-react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import type { LiveView } from "@/data/live";
|
|
6
|
+
|
|
7
|
+
// A guest holds the wheel: what they're doing, and the host's way back.
|
|
8
|
+
export function DriveBanner({
|
|
9
|
+
banner,
|
|
10
|
+
busy,
|
|
11
|
+
onTakeBack,
|
|
12
|
+
}: {
|
|
13
|
+
banner: NonNullable<LiveView["banner"]>;
|
|
14
|
+
busy: boolean;
|
|
15
|
+
onTakeBack: () => void;
|
|
16
|
+
}) {
|
|
17
|
+
return (
|
|
18
|
+
<div
|
|
19
|
+
role="status"
|
|
20
|
+
className="bg-primary/[0.06] flex items-center gap-2 rounded-xl py-1 pr-1 pl-3 shadow-sm"
|
|
21
|
+
>
|
|
22
|
+
<Users className="text-primary h-4 w-4 shrink-0" aria-hidden />
|
|
23
|
+
<div className="min-w-0 flex-1 text-sm">
|
|
24
|
+
<p className="leading-snug font-medium">{banner.text}</p>
|
|
25
|
+
<p className="text-muted-foreground truncate text-xs max-sm:hidden">
|
|
26
|
+
Every edit, command and outside read asks you first.
|
|
27
|
+
</p>
|
|
28
|
+
</div>
|
|
29
|
+
{banner.action && (
|
|
30
|
+
<Button
|
|
31
|
+
variant="outline"
|
|
32
|
+
className="min-h-11 shrink-0"
|
|
33
|
+
disabled={busy}
|
|
34
|
+
onClick={onTakeBack}
|
|
35
|
+
>
|
|
36
|
+
<Hand className="h-4 w-4" aria-hidden />
|
|
37
|
+
{banner.action.label}
|
|
38
|
+
</Button>
|
|
39
|
+
)}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
useEndLive,
|
|
20
20
|
useLiveShare,
|
|
21
21
|
type LiveExpiry,
|
|
22
|
+
type LiveInvite,
|
|
23
|
+
type LiveLevel,
|
|
22
24
|
type LiveView,
|
|
23
25
|
} from "@/data/live";
|
|
24
26
|
import { EXPIRY_LABEL } from "@/lib/live/types";
|
|
@@ -47,8 +49,8 @@ function InviteBody({ sessionId }: { sessionId: string }) {
|
|
|
47
49
|
<DialogTitle>Invite to watch live</DialogTitle>
|
|
48
50
|
<DialogDescription>
|
|
49
51
|
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
|
|
51
|
-
|
|
52
|
+
They see this chat as it happens, with secrets masked. You choose what
|
|
53
|
+
else they can do.
|
|
52
54
|
</DialogDescription>
|
|
53
55
|
</div>
|
|
54
56
|
{query.isPending ? (
|
|
@@ -60,15 +62,26 @@ function InviteBody({ sessionId }: { sessionId: string }) {
|
|
|
60
62
|
) : share ? (
|
|
61
63
|
<Link share={share} />
|
|
62
64
|
) : (
|
|
63
|
-
<Create sessionId={sessionId} />
|
|
65
|
+
<Create sessionId={sessionId} invite={query.data.invite} />
|
|
64
66
|
)}
|
|
65
67
|
</DialogContent>
|
|
66
68
|
);
|
|
67
69
|
}
|
|
68
70
|
|
|
69
|
-
function Create({
|
|
71
|
+
function Create({
|
|
72
|
+
sessionId,
|
|
73
|
+
invite,
|
|
74
|
+
}: {
|
|
75
|
+
sessionId: string;
|
|
76
|
+
invite?: LiveInvite;
|
|
77
|
+
}) {
|
|
70
78
|
const [expiresIn, setExpiresIn] = useState<LiveExpiry>("1h");
|
|
79
|
+
const [level, setLevel] = useState<LiveLevel>("suggest");
|
|
80
|
+
const [history, setHistory] = useState<boolean | null>(null);
|
|
71
81
|
const create = useCreateLive();
|
|
82
|
+
const options = invite?.levels ?? [];
|
|
83
|
+
const chosen = options.find((o) => o.value === level);
|
|
84
|
+
const withHistory = history ?? invite?.history.default ?? true;
|
|
72
85
|
return (
|
|
73
86
|
<>
|
|
74
87
|
<div className="flex flex-col gap-1.5">
|
|
@@ -77,18 +90,34 @@ function Create({ sessionId }: { sessionId: string }) {
|
|
|
77
90
|
</span>
|
|
78
91
|
<Segmented
|
|
79
92
|
label="Access"
|
|
80
|
-
value=
|
|
81
|
-
onChange={
|
|
82
|
-
options={
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
93
|
+
value={level}
|
|
94
|
+
onChange={setLevel}
|
|
95
|
+
options={options.map((o) => ({
|
|
96
|
+
value: o.value,
|
|
97
|
+
label: o.label,
|
|
98
|
+
hint: o.reason ?? o.hint,
|
|
99
|
+
disabled: !o.enabled,
|
|
100
|
+
}))}
|
|
87
101
|
/>
|
|
88
102
|
<p className="text-muted-foreground text-xs">
|
|
89
|
-
|
|
103
|
+
{chosen?.hint}
|
|
104
|
+
{options
|
|
105
|
+
.filter((o) => !o.enabled && o.reason)
|
|
106
|
+
.map((o) => ` ${o.label}: ${o.reason}.`)
|
|
107
|
+
.join("")}
|
|
90
108
|
</p>
|
|
91
109
|
</div>
|
|
110
|
+
{invite && (
|
|
111
|
+
<label className="flex min-h-11 items-center gap-3 text-sm">
|
|
112
|
+
<input
|
|
113
|
+
type="checkbox"
|
|
114
|
+
className="size-5"
|
|
115
|
+
checked={withHistory}
|
|
116
|
+
onChange={(e) => setHistory(e.target.checked)}
|
|
117
|
+
/>
|
|
118
|
+
{invite.history.label}
|
|
119
|
+
</label>
|
|
120
|
+
)}
|
|
92
121
|
<div className="flex flex-col gap-1.5">
|
|
93
122
|
<span className="text-muted-foreground text-xs font-medium">
|
|
94
123
|
The link works for
|
|
@@ -109,7 +138,9 @@ function Create({ sessionId }: { sessionId: string }) {
|
|
|
109
138
|
<Button
|
|
110
139
|
className="min-h-11"
|
|
111
140
|
disabled={create.isPending}
|
|
112
|
-
onClick={() =>
|
|
141
|
+
onClick={() =>
|
|
142
|
+
create.mutate({ sessionId, expiresIn, level, history: withHistory })
|
|
143
|
+
}
|
|
113
144
|
>
|
|
114
145
|
<Users className="h-4 w-4" />
|
|
115
146
|
{create.isPending ? "Creating…" : "Create invite link"}
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { ChevronDown, Radio, UserMinus } from "lucide-react";
|
|
5
5
|
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Segmented } from "@/components/ui/segmented";
|
|
6
7
|
import { StatusPill } from "@/components/ui/status-pill";
|
|
7
|
-
import type { LiveView } from "@/data/live";
|
|
8
|
+
import type { LiveLevel, LiveView } from "@/data/live";
|
|
8
9
|
import { cn } from "@/lib/utils";
|
|
9
10
|
|
|
10
11
|
export function presenceText(guests: LiveView["guests"]): string {
|
|
@@ -18,13 +19,15 @@ export function presenceText(guests: LiveView["guests"]): string {
|
|
|
18
19
|
export function LiveChip({
|
|
19
20
|
share,
|
|
20
21
|
busy,
|
|
21
|
-
|
|
22
|
+
onAction,
|
|
23
|
+
onLevel,
|
|
22
24
|
onEnd,
|
|
23
25
|
onInvite,
|
|
24
26
|
}: {
|
|
25
27
|
share: LiveView;
|
|
26
28
|
busy: boolean;
|
|
27
|
-
|
|
29
|
+
onAction: (guestId: string, kind: string) => void;
|
|
30
|
+
onLevel: (level: LiveLevel) => void;
|
|
28
31
|
onEnd: () => void;
|
|
29
32
|
onInvite: () => void;
|
|
30
33
|
}) {
|
|
@@ -58,6 +61,21 @@ export function LiveChip({
|
|
|
58
61
|
{share.notice && (
|
|
59
62
|
<p className="text-muted-foreground px-2 text-xs">{share.notice}</p>
|
|
60
63
|
)}
|
|
64
|
+
<Segmented
|
|
65
|
+
label="Access"
|
|
66
|
+
value={share.level}
|
|
67
|
+
onChange={onLevel}
|
|
68
|
+
disabled={busy}
|
|
69
|
+
options={share.levels.map((o) => ({
|
|
70
|
+
value: o.value,
|
|
71
|
+
label: o.label,
|
|
72
|
+
hint: o.reason ?? o.hint,
|
|
73
|
+
disabled: !o.enabled,
|
|
74
|
+
}))}
|
|
75
|
+
/>
|
|
76
|
+
<p className="text-muted-foreground px-2 text-xs">
|
|
77
|
+
{share.levels.find((o) => o.value === share.level)?.hint}
|
|
78
|
+
</p>
|
|
61
79
|
{admitted.length === 0 && (
|
|
62
80
|
<p className="text-muted-foreground px-2 py-1 text-xs">
|
|
63
81
|
No one has joined yet.
|
|
@@ -73,16 +91,19 @@ export function LiveChip({
|
|
|
73
91
|
aria-hidden
|
|
74
92
|
/>
|
|
75
93
|
<span className="min-w-0 flex-1 truncate text-sm">{g.name}</span>
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
94
|
+
{g.actions.map((a) => (
|
|
95
|
+
<Button
|
|
96
|
+
key={a.kind}
|
|
97
|
+
variant="ghost"
|
|
98
|
+
className="min-h-11"
|
|
99
|
+
disabled={busy}
|
|
100
|
+
onClick={() => onAction(g.id, a.kind)}
|
|
101
|
+
aria-label={`${a.label} ${g.name}`}
|
|
102
|
+
>
|
|
103
|
+
{a.kind === "kick" && <UserMinus className="h-4 w-4" />}
|
|
104
|
+
{a.label}
|
|
105
|
+
</Button>
|
|
106
|
+
))}
|
|
86
107
|
</div>
|
|
87
108
|
))}
|
|
88
109
|
<div className="flex gap-2 pt-1">
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
import { toast } from "sonner";
|
|
4
4
|
import { useEndLive, useLiveAction, useLiveShare } from "@/data/live";
|
|
5
5
|
import { liveUiActions } from "@/stores/liveUi";
|
|
6
|
+
import { DriveBanner } from "./DriveBanner";
|
|
6
7
|
import { GuestPromptCard } from "./GuestPromptCard";
|
|
7
8
|
import { LiveChip } from "./LiveChip";
|
|
8
9
|
import { LobbyCard } from "./LobbyCard";
|
|
9
10
|
|
|
10
11
|
// Above the composer while a session has a live link: the Live chip, who is
|
|
11
|
-
// waiting to come in, and the suggestions to send, edit or
|
|
12
|
+
// driving, who is waiting to come in, and the suggestions to send, edit or
|
|
13
|
+
// dismiss.
|
|
12
14
|
export function LivePanel({
|
|
13
15
|
sessionId,
|
|
14
16
|
title,
|
|
@@ -25,29 +27,44 @@ export function LivePanel({
|
|
|
25
27
|
if (!share) return null;
|
|
26
28
|
const busy = act.isPending || end.isPending;
|
|
27
29
|
const fail = (e: Error) => toast.error(e.message);
|
|
30
|
+
const doAction = (guestId: string, kind: string) => {
|
|
31
|
+
if (kind === "admit" || kind === "deny" || kind === "kick")
|
|
32
|
+
act.mutate({ action: kind, guestId }, { onError: fail });
|
|
33
|
+
else if (kind === "admit_drive")
|
|
34
|
+
act.mutate({ action: "admit", guestId, drive: true }, { onError: fail });
|
|
35
|
+
else if (kind === "give_wheel")
|
|
36
|
+
act.mutate({ action: "wheel", guestId }, { onError: fail });
|
|
37
|
+
else if (kind === "take_back")
|
|
38
|
+
act.mutate({ action: "wheel", guestId: null }, { onError: fail });
|
|
39
|
+
};
|
|
28
40
|
const lobby = share.guests.filter((g) => g.status === "pending");
|
|
29
41
|
return (
|
|
30
42
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-2 px-3 pb-2">
|
|
31
43
|
<LiveChip
|
|
32
44
|
share={share}
|
|
33
45
|
busy={busy}
|
|
34
|
-
|
|
35
|
-
|
|
46
|
+
onAction={doAction}
|
|
47
|
+
onLevel={(level) =>
|
|
48
|
+
act.mutate({ action: "level", level }, { onError: fail })
|
|
36
49
|
}
|
|
37
50
|
onEnd={() => end.mutate(undefined, { onError: fail })}
|
|
38
51
|
onInvite={() => liveUiActions.open(sessionId, title)}
|
|
39
52
|
/>
|
|
53
|
+
{share.banner && share.driver && (
|
|
54
|
+
<DriveBanner
|
|
55
|
+
banner={share.banner}
|
|
56
|
+
busy={busy}
|
|
57
|
+
onTakeBack={() =>
|
|
58
|
+
act.mutate({ action: "wheel", guestId: null }, { onError: fail })
|
|
59
|
+
}
|
|
60
|
+
/>
|
|
61
|
+
)}
|
|
40
62
|
{lobby.map((g) => (
|
|
41
63
|
<LobbyCard
|
|
42
64
|
key={g.id}
|
|
43
65
|
guest={g}
|
|
44
66
|
busy={busy}
|
|
45
|
-
|
|
46
|
-
act.mutate({ action: "admit", guestId: g.id }, { onError: fail })
|
|
47
|
-
}
|
|
48
|
-
onDeny={() =>
|
|
49
|
-
act.mutate({ action: "deny", guestId: g.id }, { onError: fail })
|
|
50
|
-
}
|
|
67
|
+
onAction={(kind) => doAction(g.id, kind)}
|
|
51
68
|
/>
|
|
52
69
|
))}
|
|
53
70
|
{share.suggestions.map((s) => (
|
|
@@ -10,13 +10,11 @@ type Guest = LiveView["guests"][number];
|
|
|
10
10
|
export function LobbyCard({
|
|
11
11
|
guest,
|
|
12
12
|
busy,
|
|
13
|
-
|
|
14
|
-
onDeny,
|
|
13
|
+
onAction,
|
|
15
14
|
}: {
|
|
16
15
|
guest: Guest;
|
|
17
16
|
busy: boolean;
|
|
18
|
-
|
|
19
|
-
onDeny: () => void;
|
|
17
|
+
onAction: (kind: string) => void;
|
|
20
18
|
}) {
|
|
21
19
|
return (
|
|
22
20
|
<div
|
|
@@ -41,23 +39,22 @@ export function LobbyCard({
|
|
|
41
39
|
</div>
|
|
42
40
|
</div>
|
|
43
41
|
<div className="flex gap-2">
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
</Button>
|
|
42
|
+
{guest.actions.map((a, i) => (
|
|
43
|
+
<Button
|
|
44
|
+
key={a.kind}
|
|
45
|
+
variant={i === 0 ? "default" : "outline"}
|
|
46
|
+
className="min-h-11 flex-1 sm:flex-none"
|
|
47
|
+
disabled={busy}
|
|
48
|
+
onClick={() => onAction(a.kind)}
|
|
49
|
+
>
|
|
50
|
+
{a.kind === "admit" ? (
|
|
51
|
+
<Check className="h-4 w-4" />
|
|
52
|
+
) : a.kind === "deny" ? (
|
|
53
|
+
<X className="h-4 w-4" />
|
|
54
|
+
) : null}
|
|
55
|
+
{a.label}
|
|
56
|
+
</Button>
|
|
57
|
+
))}
|
|
61
58
|
</div>
|
|
62
59
|
</div>
|
|
63
60
|
);
|
|
@@ -6,6 +6,7 @@ export interface SegmentedOption<T extends string> {
|
|
|
6
6
|
value: T;
|
|
7
7
|
label: string;
|
|
8
8
|
hint?: string;
|
|
9
|
+
disabled?: boolean;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
/** One choice out of a few, side by side. Radio semantics, 44px targets. */
|
|
@@ -37,7 +38,7 @@ export function Segmented<T extends string>({
|
|
|
37
38
|
role="radio"
|
|
38
39
|
aria-checked={on}
|
|
39
40
|
title={o.hint}
|
|
40
|
-
disabled={disabled}
|
|
41
|
+
disabled={disabled || o.disabled}
|
|
41
42
|
onClick={() => !on && onChange(o.value)}
|
|
42
43
|
className={cn(
|
|
43
44
|
"min-h-11 rounded-md px-2 text-sm font-medium transition-colors disabled:opacity-50",
|