@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.
Files changed (43) hide show
  1. package/app/api/live-shares/[id]/route.ts +21 -3
  2. package/app/api/live-shares/route.test.ts +36 -0
  3. package/app/api/live-shares/route.ts +14 -3
  4. package/components/Chat/Approval.tsx +13 -2
  5. package/components/Chat/EventRow.tsx +5 -0
  6. package/components/Chat/Queue.tsx +27 -15
  7. package/components/Live/DriveBanner.tsx +42 -0
  8. package/components/Live/InviteDialog.tsx +44 -13
  9. package/components/Live/LiveChip.tsx +34 -13
  10. package/components/Live/LivePanel.tsx +26 -9
  11. package/components/Live/LobbyCard.tsx +18 -21
  12. package/components/ui/segmented.tsx +2 -1
  13. package/data/live/queries.ts +25 -5
  14. package/lib/chat/driver.ts +4 -0
  15. package/lib/chat/drivers/claude-approvals.ts +93 -25
  16. package/lib/chat/drivers/claude.ts +2 -1
  17. package/lib/chat/events.ts +21 -1
  18. package/lib/chat/queued.ts +57 -10
  19. package/lib/chat/registry.ts +2 -0
  20. package/lib/chat/runner.ts +65 -2
  21. package/lib/chat/socket.ts +12 -9
  22. package/lib/chat/worker/gate-testing.ts +131 -0
  23. package/lib/chat/worker/guest-gate.ts +511 -0
  24. package/lib/chat/worker/host.ts +50 -6
  25. package/lib/chat/worker/main.ts +16 -2
  26. package/lib/chat/worker/protocol.ts +11 -0
  27. package/lib/connect/control-api.ts +6 -2
  28. package/lib/db/migrations/20261030T1200_live_share_level.ts +19 -0
  29. package/lib/db/migrations/20261030T1300_chat_guest_steers.ts +20 -0
  30. package/lib/db/migrations/20261030T1400_chat_guest_steers_ended.ts +14 -0
  31. package/lib/live/clean.ts +6 -0
  32. package/lib/live/drive.ts +398 -0
  33. package/lib/live/frames.ts +9 -2
  34. package/lib/live/inbox.ts +47 -1
  35. package/lib/live/levels.ts +74 -0
  36. package/lib/live/publisher.ts +10 -0
  37. package/lib/live/runtime.ts +20 -0
  38. package/lib/live/service.ts +133 -10
  39. package/lib/live/store.ts +54 -3
  40. package/lib/live/testing.ts +281 -0
  41. package/lib/live/types.ts +52 -0
  42. package/lib/share/connect.ts +1 -0
  43. 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({ action: z.literal("admit"), guestId: z.string().min(1).max(64) }),
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(body.data.sessionId, body.data.expiresIn);
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({ linked: linked(), shares: listLive(sessionId) });
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}&apos;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
- {editing?.id === m.id ? (
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
- <IconButton
103
- label="Send now"
104
- onClick={() => onSendNow(m.id)}
105
- className="text-foreground"
106
- >
107
- <SendHorizontal className="h-4 w-4" />
108
- </IconButton>
109
- <IconButton
110
- label="Edit"
111
- onClick={() => setEditing({ id: m.id, text: m.text })}
112
- className="max-md:hidden"
113
- >
114
- <Pencil className="h-3.5 w-3.5" />
115
- </IconButton>
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, and can suggest
51
- a prompt. Nothing they type reaches the agent unless you tap Send.
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({ sessionId }: { sessionId: string }) {
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="watch"
81
- onChange={() => {}}
82
- options={[
83
- { value: "watch", label: "Watch + suggest" },
84
- { value: "drive", label: "Drive (later)" },
85
- ]}
86
- disabled
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
- Driving the agent from a guest link isn&apos;t available yet.
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={() => create.mutate({ sessionId, expiresIn })}
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
- onKick,
22
+ onAction,
23
+ onLevel,
22
24
  onEnd,
23
25
  onInvite,
24
26
  }: {
25
27
  share: LiveView;
26
28
  busy: boolean;
27
- onKick: (guestId: string) => void;
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
- <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>
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 dismiss.
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
- onKick={(guestId) =>
35
- act.mutate({ action: "kick", guestId }, { onError: fail })
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
- onAdmit={() =>
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
- onAdmit,
14
- onDeny,
13
+ onAction,
15
14
  }: {
16
15
  guest: Guest;
17
16
  busy: boolean;
18
- onAdmit: () => void;
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
- <Button
45
- className="min-h-11 flex-1 sm:flex-none"
46
- disabled={busy}
47
- onClick={onAdmit}
48
- >
49
- <Check className="h-4 w-4" />
50
- Admit
51
- </Button>
52
- <Button
53
- variant="outline"
54
- className="min-h-11 flex-1 sm:flex-none"
55
- disabled={busy}
56
- onClick={onDeny}
57
- >
58
- <X className="h-4 w-4" />
59
- Deny
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",