@saadnvd1/agent-os 0.3.779 → 0.3.781
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 +30 -4
- package/app/api/live-shares/route.test.ts +37 -2
- package/app/api/live-shares/route.ts +8 -2
- 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 +73 -2
- package/components/Live/LiveChip.tsx +68 -1
- package/components/Live/LivePanel.tsx +26 -1
- package/data/live/queries.ts +6 -1
- 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 +30 -1
- 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/db/migrations/20261031T1200_live_share_access_code.ts +14 -0
- package/lib/live/clean.ts +6 -0
- package/lib/live/code-view.ts +56 -0
- package/lib/live/code.ts +25 -0
- package/lib/live/drive.ts +398 -0
- package/lib/live/frames.ts +9 -2
- package/lib/live/inbox.ts +50 -3
- package/lib/live/levels.ts +55 -10
- package/lib/live/runtime.ts +22 -0
- package/lib/live/service.ts +151 -16
- package/lib/live/store.ts +56 -3
- package/lib/live/testing.ts +281 -0
- package/lib/live/types.ts +37 -1
- package/lib/share/connect.ts +20 -0
- package/package.json +1 -1
|
@@ -3,20 +3,41 @@ import { z } from "zod";
|
|
|
3
3
|
import { fail, readJson, refuseDemo, toResponse } from "@/lib/share/route";
|
|
4
4
|
import {
|
|
5
5
|
admit,
|
|
6
|
+
changeCode,
|
|
6
7
|
changeLevel,
|
|
7
8
|
deny,
|
|
8
9
|
dismissSuggestion,
|
|
9
10
|
endLive,
|
|
11
|
+
giveWheel,
|
|
10
12
|
kick,
|
|
11
13
|
sendSuggestion,
|
|
12
14
|
startLive,
|
|
13
15
|
} from "@/lib/live/service";
|
|
14
16
|
|
|
15
17
|
const Action = z.discriminatedUnion("action", [
|
|
16
|
-
z.object({
|
|
18
|
+
z.object({
|
|
19
|
+
action: z.literal("admit"),
|
|
20
|
+
guestId: z.string().min(1).max(64),
|
|
21
|
+
drive: z.boolean().optional(),
|
|
22
|
+
}),
|
|
17
23
|
z.object({ action: z.literal("deny"), guestId: z.string().min(1).max(64) }),
|
|
18
24
|
z.object({ action: z.literal("kick"), guestId: z.string().min(1).max(64) }),
|
|
19
|
-
z.object({
|
|
25
|
+
z.object({
|
|
26
|
+
action: z.literal("level"),
|
|
27
|
+
level: z.enum(["watch", "suggest", "drive"]),
|
|
28
|
+
}),
|
|
29
|
+
// Gives the wheel to an admitted guest of a Drive share; null takes it back.
|
|
30
|
+
z.object({
|
|
31
|
+
action: z.literal("wheel"),
|
|
32
|
+
guestId: z.string().min(1).max(64).nullable(),
|
|
33
|
+
}),
|
|
34
|
+
// Sets the access code to the host's own, or turns it off (null).
|
|
35
|
+
z.object({
|
|
36
|
+
action: z.literal("code"),
|
|
37
|
+
code: z.string().max(40).nullable(),
|
|
38
|
+
}),
|
|
39
|
+
// Turns the access code on, or changes it, to a generated one.
|
|
40
|
+
z.object({ action: z.literal("new_code") }),
|
|
20
41
|
z.object({
|
|
21
42
|
action: z.literal("send"),
|
|
22
43
|
suggestionId: z.string().min(1).max(64),
|
|
@@ -29,7 +50,8 @@ const Action = z.discriminatedUnion("action", [
|
|
|
29
50
|
]);
|
|
30
51
|
|
|
31
52
|
// POST /api/live-shares/:id - the host's decisions: let a guest in, turn one
|
|
32
|
-
// away, remove one, send a suggestion to the agent or dismiss it
|
|
53
|
+
// away, remove one, send a suggestion to the agent or dismiss it, change the
|
|
54
|
+
// level, give or take back the wheel.
|
|
33
55
|
export async function POST(
|
|
34
56
|
request: Request,
|
|
35
57
|
{ params }: { params: Promise<{ id: string }> }
|
|
@@ -44,10 +66,14 @@ export async function POST(
|
|
|
44
66
|
startLive();
|
|
45
67
|
try {
|
|
46
68
|
const a = body.data;
|
|
47
|
-
if (a.action === "admit") admit(id, a.guestId);
|
|
69
|
+
if (a.action === "admit") admit(id, a.guestId, { drive: a.drive });
|
|
48
70
|
else if (a.action === "deny") deny(id, a.guestId);
|
|
49
71
|
else if (a.action === "kick") kick(id, a.guestId);
|
|
50
72
|
else if (a.action === "level") changeLevel(id, a.level);
|
|
73
|
+
else if (a.action === "wheel") giveWheel(id, a.guestId);
|
|
74
|
+
else if (a.action === "code")
|
|
75
|
+
await changeCode(id, a.code === null ? null : { code: a.code });
|
|
76
|
+
else if (a.action === "new_code") await changeCode(id, { generate: true });
|
|
51
77
|
else if (a.action === "send")
|
|
52
78
|
await sendSuggestion(id, a.suggestionId, a.text);
|
|
53
79
|
else dismissSuggestion(id, a.suggestionId);
|
|
@@ -43,9 +43,24 @@ describe("live-shares routes", () => {
|
|
|
43
43
|
);
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
-
it("
|
|
46
|
+
it("validate the access code shape (400) and accept the code actions", async () => {
|
|
47
|
+
expect(
|
|
48
|
+
(await POST(req({ sessionId: "s", code: { custom: 5 } }))).status
|
|
49
|
+
).toBe(400);
|
|
50
|
+
expect((await POST(req({ sessionId: "s", code: {} }))).status).toBe(409);
|
|
51
|
+
expect((await POST(req({ sessionId: "s", code: null }))).status).toBe(409);
|
|
52
|
+
// A bad own code on a share this machine holds nothing of: 404, so it validated.
|
|
53
|
+
expect((await act(req({ action: "code", code: "ab" }), ctx)).status).toBe(
|
|
54
|
+
404
|
|
55
|
+
);
|
|
56
|
+
expect((await act(req({ action: "code", code: 5 }), ctx)).status).toBe(400);
|
|
57
|
+
expect((await act(req({ action: "new_code" }), ctx)).status).toBe(404);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("accept watch, suggest and drive, and history as a boolean", async () => {
|
|
61
|
+
// Past validation: not linked to Connect here, so 409, never 400.
|
|
47
62
|
expect((await POST(req({ sessionId: "s", level: "drive" }))).status).toBe(
|
|
48
|
-
|
|
63
|
+
409
|
|
49
64
|
);
|
|
50
65
|
expect((await POST(req({ sessionId: "s", level: "root" }))).status).toBe(
|
|
51
66
|
400
|
|
@@ -53,8 +68,28 @@ describe("live-shares routes", () => {
|
|
|
53
68
|
expect((await POST(req({ sessionId: "s", history: "yes" }))).status).toBe(
|
|
54
69
|
400
|
|
55
70
|
);
|
|
71
|
+
// A level for a share this machine doesn't hold: 404, so it validated.
|
|
56
72
|
expect(
|
|
57
73
|
(await act(req({ action: "level", level: "drive" }), ctx)).status
|
|
74
|
+
).toBe(404);
|
|
75
|
+
expect(
|
|
76
|
+
(await act(req({ action: "level", level: "admin" }), ctx)).status
|
|
58
77
|
).toBe(400);
|
|
59
78
|
});
|
|
79
|
+
|
|
80
|
+
it("validate the wheel and admit-to-drive actions", async () => {
|
|
81
|
+
for (const body of [
|
|
82
|
+
{ action: "wheel" },
|
|
83
|
+
{ action: "wheel", guestId: 7 },
|
|
84
|
+
{ action: "wheel", guestId: "" },
|
|
85
|
+
{ action: "admit", guestId: "g", drive: "yes" },
|
|
86
|
+
])
|
|
87
|
+
expect((await act(req(body), ctx)).status).toBe(400);
|
|
88
|
+
for (const body of [
|
|
89
|
+
{ action: "wheel", guestId: "g1" },
|
|
90
|
+
{ action: "wheel", guestId: null },
|
|
91
|
+
{ action: "admit", guestId: "g", drive: true },
|
|
92
|
+
])
|
|
93
|
+
expect((await act(req(body), ctx)).status).toBe(404);
|
|
94
|
+
});
|
|
60
95
|
});
|
|
@@ -7,8 +7,13 @@ import { createLive, inviteFor, listLive, startLive } from "@/lib/live/service";
|
|
|
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"]).default("suggest"),
|
|
10
|
+
level: z.enum(["watch", "suggest", "drive"]).default("suggest"),
|
|
11
11
|
history: z.boolean().default(true),
|
|
12
|
+
// Absent or null: no code. {} generates one; { custom } uses the host's.
|
|
13
|
+
code: z
|
|
14
|
+
.object({ custom: z.string().max(40).optional() })
|
|
15
|
+
.nullable()
|
|
16
|
+
.optional(),
|
|
12
17
|
});
|
|
13
18
|
|
|
14
19
|
// POST /api/live-shares - mint a live link for one chat and start publishing.
|
|
@@ -25,7 +30,8 @@ export async function POST(request: Request) {
|
|
|
25
30
|
body.data.sessionId,
|
|
26
31
|
body.data.expiresIn,
|
|
27
32
|
body.data.level,
|
|
28
|
-
body.data.history
|
|
33
|
+
body.data.history,
|
|
34
|
+
body.data.code
|
|
29
35
|
);
|
|
30
36
|
return NextResponse.json(view, { status: 201 });
|
|
31
37
|
} catch (err) {
|
|
@@ -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
|
+
}
|
|
@@ -78,10 +78,13 @@ function Create({
|
|
|
78
78
|
const [expiresIn, setExpiresIn] = useState<LiveExpiry>("1h");
|
|
79
79
|
const [level, setLevel] = useState<LiveLevel>("suggest");
|
|
80
80
|
const [history, setHistory] = useState<boolean | null>(null);
|
|
81
|
+
const [codeOn, setCodeOn] = useState<boolean | null>(null);
|
|
82
|
+
const [custom, setCustom] = useState("");
|
|
81
83
|
const create = useCreateLive();
|
|
82
84
|
const options = invite?.levels ?? [];
|
|
83
85
|
const chosen = options.find((o) => o.value === level);
|
|
84
86
|
const withHistory = history ?? invite?.history.default ?? true;
|
|
87
|
+
const withCode = codeOn ?? invite?.code.default ?? false;
|
|
85
88
|
return (
|
|
86
89
|
<>
|
|
87
90
|
<div className="flex flex-col gap-1.5">
|
|
@@ -118,6 +121,36 @@ function Create({
|
|
|
118
121
|
{invite.history.label}
|
|
119
122
|
</label>
|
|
120
123
|
)}
|
|
124
|
+
{invite && (
|
|
125
|
+
<div className="flex flex-col gap-1.5">
|
|
126
|
+
<label className="flex min-h-11 items-center gap-3 text-sm">
|
|
127
|
+
<input
|
|
128
|
+
type="checkbox"
|
|
129
|
+
className="size-5"
|
|
130
|
+
checked={withCode}
|
|
131
|
+
onChange={(e) => setCodeOn(e.target.checked)}
|
|
132
|
+
/>
|
|
133
|
+
{invite.code.label}
|
|
134
|
+
</label>
|
|
135
|
+
{withCode && (
|
|
136
|
+
<>
|
|
137
|
+
<input
|
|
138
|
+
value={custom}
|
|
139
|
+
onChange={(e) => setCustom(e.target.value)}
|
|
140
|
+
maxLength={40}
|
|
141
|
+
autoComplete="off"
|
|
142
|
+
spellCheck={false}
|
|
143
|
+
placeholder={invite.code.customLabel}
|
|
144
|
+
aria-label={invite.code.customLabel}
|
|
145
|
+
className="bg-muted min-h-11 rounded-lg px-3 font-mono text-sm"
|
|
146
|
+
/>
|
|
147
|
+
<p className="text-muted-foreground text-xs">
|
|
148
|
+
{invite.code.hint} {invite.code.rules}
|
|
149
|
+
</p>
|
|
150
|
+
</>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
121
154
|
<div className="flex flex-col gap-1.5">
|
|
122
155
|
<span className="text-muted-foreground text-xs font-medium">
|
|
123
156
|
The link works for
|
|
@@ -139,7 +172,17 @@ function Create({
|
|
|
139
172
|
className="min-h-11"
|
|
140
173
|
disabled={create.isPending}
|
|
141
174
|
onClick={() =>
|
|
142
|
-
create.mutate({
|
|
175
|
+
create.mutate({
|
|
176
|
+
sessionId,
|
|
177
|
+
expiresIn,
|
|
178
|
+
level,
|
|
179
|
+
history: withHistory,
|
|
180
|
+
code: withCode
|
|
181
|
+
? custom.trim()
|
|
182
|
+
? { custom: custom.trim() }
|
|
183
|
+
: {}
|
|
184
|
+
: null,
|
|
185
|
+
})
|
|
143
186
|
}
|
|
144
187
|
>
|
|
145
188
|
<Users className="h-4 w-4" />
|
|
@@ -153,6 +196,7 @@ function Link({ share }: { share: LiveView }) {
|
|
|
153
196
|
const { copied, copy } = useCopyToClipboard();
|
|
154
197
|
const end = useEndLive(share.id);
|
|
155
198
|
const qr = useQr(share.url);
|
|
199
|
+
const code = share.code;
|
|
156
200
|
const canShare = typeof navigator !== "undefined" && "share" in navigator;
|
|
157
201
|
return (
|
|
158
202
|
<>
|
|
@@ -175,6 +219,33 @@ function Link({ share }: { share: LiveView }) {
|
|
|
175
219
|
className="bg-muted min-h-11 w-full rounded-lg px-3 font-mono text-xs"
|
|
176
220
|
/>
|
|
177
221
|
</div>
|
|
222
|
+
{code.on && code.copyText && (
|
|
223
|
+
<div className="flex flex-col items-center gap-2">
|
|
224
|
+
<span className="text-muted-foreground text-xs font-medium">
|
|
225
|
+
{code.label}
|
|
226
|
+
</span>
|
|
227
|
+
<span
|
|
228
|
+
className="font-mono text-3xl font-semibold tracking-widest"
|
|
229
|
+
aria-label={code.label}
|
|
230
|
+
>
|
|
231
|
+
{code.display}
|
|
232
|
+
</span>
|
|
233
|
+
<Button
|
|
234
|
+
className="min-h-11 w-full"
|
|
235
|
+
onClick={() => copy(code.copyText!)}
|
|
236
|
+
>
|
|
237
|
+
{copied ? (
|
|
238
|
+
<Check className="h-4 w-4" />
|
|
239
|
+
) : (
|
|
240
|
+
<Copy className="h-4 w-4" />
|
|
241
|
+
)}
|
|
242
|
+
Copy link and code
|
|
243
|
+
</Button>
|
|
244
|
+
<p className="text-muted-foreground text-center text-xs">
|
|
245
|
+
{code.hint}
|
|
246
|
+
</p>
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
178
249
|
<div className="flex flex-wrap gap-2">
|
|
179
250
|
<Button
|
|
180
251
|
variant="outline"
|
|
@@ -186,7 +257,7 @@ function Link({ share }: { share: LiveView }) {
|
|
|
186
257
|
) : (
|
|
187
258
|
<Copy className="h-4 w-4" />
|
|
188
259
|
)}
|
|
189
|
-
Copy
|
|
260
|
+
Copy link
|
|
190
261
|
</Button>
|
|
191
262
|
{canShare && (
|
|
192
263
|
<Button
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useState } from "react";
|
|
4
|
-
import { ChevronDown, Radio, UserMinus } from "lucide-react";
|
|
4
|
+
import { Check, ChevronDown, Copy, Radio, UserMinus } from "lucide-react";
|
|
5
5
|
import { Button } from "@/components/ui/button";
|
|
6
6
|
import { Segmented } from "@/components/ui/segmented";
|
|
7
7
|
import { StatusPill } from "@/components/ui/status-pill";
|
|
8
8
|
import type { LiveLevel, LiveView } from "@/data/live";
|
|
9
|
+
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
|
|
9
10
|
import { cn } from "@/lib/utils";
|
|
10
11
|
|
|
11
12
|
export function presenceText(guests: LiveView["guests"]): string {
|
|
@@ -21,6 +22,7 @@ export function LiveChip({
|
|
|
21
22
|
busy,
|
|
22
23
|
onAction,
|
|
23
24
|
onLevel,
|
|
25
|
+
onCode,
|
|
24
26
|
onEnd,
|
|
25
27
|
onInvite,
|
|
26
28
|
}: {
|
|
@@ -28,6 +30,7 @@ export function LiveChip({
|
|
|
28
30
|
busy: boolean;
|
|
29
31
|
onAction: (guestId: string, kind: string) => void;
|
|
30
32
|
onLevel: (level: LiveLevel) => void;
|
|
33
|
+
onCode: (kind: string, own?: string) => void;
|
|
31
34
|
onEnd: () => void;
|
|
32
35
|
onInvite: () => void;
|
|
33
36
|
}) {
|
|
@@ -76,6 +79,7 @@ export function LiveChip({
|
|
|
76
79
|
<p className="text-muted-foreground px-2 text-xs">
|
|
77
80
|
{share.levels.find((o) => o.value === share.level)?.hint}
|
|
78
81
|
</p>
|
|
82
|
+
<CodeRow share={share} busy={busy} onCode={onCode} />
|
|
79
83
|
{admitted.length === 0 && (
|
|
80
84
|
<p className="text-muted-foreground px-2 py-1 text-xs">
|
|
81
85
|
No one has joined yet.
|
|
@@ -128,3 +132,66 @@ export function LiveChip({
|
|
|
128
132
|
</div>
|
|
129
133
|
);
|
|
130
134
|
}
|
|
135
|
+
|
|
136
|
+
function CodeRow({
|
|
137
|
+
share,
|
|
138
|
+
busy,
|
|
139
|
+
onCode,
|
|
140
|
+
}: {
|
|
141
|
+
share: LiveView;
|
|
142
|
+
busy: boolean;
|
|
143
|
+
onCode: (kind: string, own?: string) => void;
|
|
144
|
+
}) {
|
|
145
|
+
const { copied, copy } = useCopyToClipboard();
|
|
146
|
+
const { code } = share;
|
|
147
|
+
const change = (kind: string) => {
|
|
148
|
+
if (kind === "copy") copy(code.copyText ?? share.url);
|
|
149
|
+
else if (kind === "copy_link") copy(share.url);
|
|
150
|
+
else if (kind === "set_code") {
|
|
151
|
+
const own = window.prompt(`${code.prompt.title}\n${code.prompt.message}`);
|
|
152
|
+
if (own?.trim()) onCode(kind, own.trim());
|
|
153
|
+
} else onCode(kind);
|
|
154
|
+
};
|
|
155
|
+
return (
|
|
156
|
+
<div className="flex flex-col gap-1 px-2 py-1">
|
|
157
|
+
<div className="flex min-h-11 items-center gap-2">
|
|
158
|
+
<span className="text-muted-foreground text-xs">{code.label}</span>
|
|
159
|
+
<span className="min-w-0 flex-1 truncate font-mono text-sm font-semibold tracking-wider">
|
|
160
|
+
{code.on ? code.display : "Off"}
|
|
161
|
+
</span>
|
|
162
|
+
{code.on && code.copyText && (
|
|
163
|
+
<Button
|
|
164
|
+
variant="ghost"
|
|
165
|
+
className="min-h-11"
|
|
166
|
+
onClick={() => copy(code.copyText!)}
|
|
167
|
+
aria-label="Copy link and code"
|
|
168
|
+
>
|
|
169
|
+
{copied ? (
|
|
170
|
+
<Check className="h-4 w-4" />
|
|
171
|
+
) : (
|
|
172
|
+
<Copy className="h-4 w-4" />
|
|
173
|
+
)}
|
|
174
|
+
</Button>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
{code.notice && (
|
|
178
|
+
<p className="text-muted-foreground text-xs">{code.notice}</p>
|
|
179
|
+
)}
|
|
180
|
+
<div className="flex flex-wrap gap-1">
|
|
181
|
+
{code.actions
|
|
182
|
+
.filter((a) => a.kind !== "copy" && a.kind !== "copy_link")
|
|
183
|
+
.map((a) => (
|
|
184
|
+
<Button
|
|
185
|
+
key={a.kind}
|
|
186
|
+
variant="ghost"
|
|
187
|
+
className="min-h-11"
|
|
188
|
+
disabled={busy}
|
|
189
|
+
onClick={() => change(a.kind)}
|
|
190
|
+
>
|
|
191
|
+
{a.label}
|
|
192
|
+
</Button>
|
|
193
|
+
))}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
@@ -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,
|
|
@@ -28,6 +30,12 @@ export function LivePanel({
|
|
|
28
30
|
const doAction = (guestId: string, kind: string) => {
|
|
29
31
|
if (kind === "admit" || kind === "deny" || kind === "kick")
|
|
30
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 });
|
|
31
39
|
};
|
|
32
40
|
const lobby = share.guests.filter((g) => g.status === "pending");
|
|
33
41
|
return (
|
|
@@ -39,9 +47,26 @@ export function LivePanel({
|
|
|
39
47
|
onLevel={(level) =>
|
|
40
48
|
act.mutate({ action: "level", level }, { onError: fail })
|
|
41
49
|
}
|
|
50
|
+
onCode={(kind, own) => {
|
|
51
|
+
if (kind === "code_off")
|
|
52
|
+
act.mutate({ action: "code", code: null }, { onError: fail });
|
|
53
|
+
else if (kind === "set_code" && own)
|
|
54
|
+
act.mutate({ action: "code", code: own }, { onError: fail });
|
|
55
|
+
else if (kind === "new_code" || kind === "code_on")
|
|
56
|
+
act.mutate({ action: "new_code" }, { onError: fail });
|
|
57
|
+
}}
|
|
42
58
|
onEnd={() => end.mutate(undefined, { onError: fail })}
|
|
43
59
|
onInvite={() => liveUiActions.open(sessionId, title)}
|
|
44
60
|
/>
|
|
61
|
+
{share.banner && share.driver && (
|
|
62
|
+
<DriveBanner
|
|
63
|
+
banner={share.banner}
|
|
64
|
+
busy={busy}
|
|
65
|
+
onTakeBack={() =>
|
|
66
|
+
act.mutate({ action: "wheel", guestId: null }, { onError: fail })
|
|
67
|
+
}
|
|
68
|
+
/>
|
|
69
|
+
)}
|
|
45
70
|
{lobby.map((g) => (
|
|
46
71
|
<LobbyCard
|
|
47
72
|
key={g.id}
|
package/data/live/queries.ts
CHANGED
|
@@ -57,14 +57,19 @@ export function useCreateLive() {
|
|
|
57
57
|
expiresIn: LiveExpiry;
|
|
58
58
|
level: LiveLevel;
|
|
59
59
|
history: boolean;
|
|
60
|
+
code?: { custom?: string } | null;
|
|
60
61
|
}) => json<LiveView>(await send("/api/live-shares", "POST", req)),
|
|
61
62
|
onSuccess: () => qc.invalidateQueries({ queryKey: liveKeys.all }),
|
|
62
63
|
});
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
export type LiveAction =
|
|
66
|
-
| { action: "admit"
|
|
67
|
+
| { action: "admit"; guestId: string; drive?: boolean }
|
|
68
|
+
| { action: "deny" | "kick"; guestId: string }
|
|
69
|
+
| { action: "wheel"; guestId: string | null }
|
|
67
70
|
| { action: "level"; level: LiveLevel }
|
|
71
|
+
| { action: "code"; code: string | null }
|
|
72
|
+
| { action: "new_code" }
|
|
68
73
|
| { action: "send"; suggestionId: string; text?: string }
|
|
69
74
|
| { action: "dismiss"; suggestionId: string };
|
|
70
75
|
|
package/lib/chat/driver.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
McpServerConfig,
|
|
3
3
|
PermissionMode,
|
|
4
4
|
} from "@anthropic-ai/claude-agent-sdk";
|
|
5
|
+
import type { GuestGate } from "./worker/guest-gate";
|
|
5
6
|
import type {
|
|
6
7
|
ApprovalDecision,
|
|
7
8
|
ChatAccess,
|
|
@@ -60,6 +61,9 @@ export interface ChatStartOptions {
|
|
|
60
61
|
// refused, null to let it go on to them, or a warning the agent is shown
|
|
61
62
|
// while the call still goes on.
|
|
62
63
|
toolGuard?: ToolGuard;
|
|
64
|
+
// Holds a live guest's turn to the host's taps (./worker/guest-gate). A
|
|
65
|
+
// driver without it can't take Drive prompts (the worker's caps say so).
|
|
66
|
+
guestGate?: GuestGate;
|
|
63
67
|
// The note handed back to the agent right after its context is compacted
|
|
64
68
|
// (lib/chat/compact-context), empty for none. Only Claude Code has a hook
|
|
65
69
|
// for it; other drivers ignore it.
|