@saadnvd1/agent-os 0.3.770 → 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.
Files changed (89) hide show
  1. package/app/api/live-shares/[id]/route.ts +72 -0
  2. package/app/api/live-shares/route.test.ts +45 -0
  3. package/app/api/live-shares/route.ts +40 -0
  4. package/app/api/sessions/[id]/artifacts/[artifactId]/compare/route.ts +41 -0
  5. package/app/api/shares/preview/route.ts +6 -1
  6. package/app/api/shares/route.test.ts +51 -0
  7. package/app/page.tsx +6 -0
  8. package/components/Artifacts/ArtifactActions.tsx +27 -3
  9. package/components/Artifacts/ArtifactPreview.tsx +27 -12
  10. package/components/Artifacts/Compare/DiffView.tsx +116 -0
  11. package/components/Artifacts/Compare/SideBySide.tsx +60 -0
  12. package/components/Artifacts/Compare/VersionPicker.tsx +47 -0
  13. package/components/Artifacts/Compare/index.tsx +191 -0
  14. package/components/Chat/ChatPanel.tsx +6 -0
  15. package/components/Chat/EventRow.tsx +6 -0
  16. package/components/Chat/Messages.tsx +6 -0
  17. package/components/Chat/ShareFromHere.tsx +39 -0
  18. package/components/CommandPalette/CommandPalette.tsx +5 -4
  19. package/components/CommandPalette/CommandsBody.tsx +97 -131
  20. package/components/CommandPalette/PaletteRows.tsx +79 -0
  21. package/components/CommandPalette/PaletteTabs.tsx +51 -0
  22. package/components/CommandPalette/conversations/ConversationSearch.tsx +13 -11
  23. package/components/CommandPalette/useAppCommands.ts +14 -0
  24. package/components/CommandPalette/useConversationRows.ts +55 -0
  25. package/components/Live/GuestPromptCard.tsx +64 -0
  26. package/components/Live/InviteDialog.tsx +225 -0
  27. package/components/Live/LiveChip.tsx +109 -0
  28. package/components/Live/LivePanel.tsx +81 -0
  29. package/components/Live/LobbyCard.tsx +64 -0
  30. package/components/Pane/MobileTabBar.tsx +8 -0
  31. package/components/Pane/PaneMenu.tsx +10 -0
  32. package/components/SessionList/SessionRowMenu.tsx +6 -0
  33. package/components/SessionList/useSessionSwitching.ts +2 -2
  34. package/components/Share/MessageList.tsx +87 -0
  35. package/components/Share/RangePicker.tsx +59 -0
  36. package/components/Share/ShareDialog.tsx +113 -52
  37. package/components/Shares/SharedLinksPanel.tsx +51 -0
  38. package/data/live/index.ts +2 -0
  39. package/data/live/keys.ts +4 -0
  40. package/data/live/queries.ts +81 -0
  41. package/data/session-artifacts/keys.ts +2 -0
  42. package/data/session-artifacts/queries.ts +25 -0
  43. package/data/shares/queries.ts +11 -0
  44. package/hooks/usePaletteCommands.ts +4 -1
  45. package/lib/artifacts/actions.ts +11 -1
  46. package/lib/artifacts/compare/compareVersions.ts +66 -0
  47. package/lib/artifacts/compare/diff.ts +178 -0
  48. package/lib/artifacts/compare/extractText.ts +64 -0
  49. package/lib/artifacts/compare/index.ts +11 -0
  50. package/lib/artifacts/compare/types.ts +44 -0
  51. package/lib/artifacts/match.ts +64 -0
  52. package/lib/artifacts/store.ts +39 -10
  53. package/lib/artifacts/tools.ts +9 -4
  54. package/lib/artifacts/versions.ts +2 -1
  55. package/lib/chat/events.ts +4 -1
  56. package/lib/chat/registry.ts +10 -0
  57. package/lib/connect/control-api.ts +44 -0
  58. package/lib/db/migrations/20261029T1400_live_shares.ts +42 -0
  59. package/lib/live/clean.ts +54 -0
  60. package/lib/live/frames.ts +132 -0
  61. package/lib/live/inbox.ts +225 -0
  62. package/lib/live/publisher.ts +84 -0
  63. package/lib/live/ring.ts +61 -0
  64. package/lib/live/runtime.ts +51 -0
  65. package/lib/live/service.ts +318 -0
  66. package/lib/live/socket.ts +293 -0
  67. package/lib/live/store.ts +149 -0
  68. package/lib/live/types.ts +39 -0
  69. package/lib/palette/registry.ts +71 -17
  70. package/lib/palette/sections.ts +143 -0
  71. package/lib/palette/tabs.ts +21 -0
  72. package/lib/palette/usage.ts +81 -0
  73. package/lib/security/demo.ts +3 -0
  74. package/lib/share/connect.ts +24 -0
  75. package/lib/share/markdown.ts +3 -0
  76. package/lib/share/range.ts +189 -0
  77. package/lib/share/redact.ts +23 -1
  78. package/lib/share/render.tsx +3 -0
  79. package/lib/share/secret-patterns.ts +77 -0
  80. package/lib/share/service.ts +64 -19
  81. package/lib/share/snapshot.ts +39 -2
  82. package/lib/share/styles.ts +2 -0
  83. package/lib/share/view.ts +11 -0
  84. package/lib/sidebar/row-menu.ts +2 -0
  85. package/package.json +1 -1
  86. package/server.ts +2 -0
  87. package/stores/liveUi.ts +17 -0
  88. package/stores/palette.ts +13 -11
  89. package/stores/shareUi.ts +6 -1
@@ -0,0 +1,79 @@
1
+ "use client";
2
+
3
+ import type { LucideIcon } from "lucide-react";
4
+ import type { PaletteCommand } from "@/lib/palette/registry";
5
+ import type { Section } from "@/lib/palette/sections";
6
+ import { cn } from "@/lib/utils";
7
+
8
+ // The sections' rows under their headings, numbered in order so the caller's
9
+ // highlight and Enter agree with what's drawn.
10
+ export function PaletteRows({
11
+ sections,
12
+ highlighted,
13
+ onHover,
14
+ onRun,
15
+ }: {
16
+ sections: Section[];
17
+ highlighted: number;
18
+ onHover: (i: number) => void;
19
+ onRun: (c: PaletteCommand) => void;
20
+ }) {
21
+ let index = 0;
22
+ return (
23
+ <>
24
+ {sections.map((s) => (
25
+ <div key={s.name || "results"} className="mb-1">
26
+ {s.name && (
27
+ <p className="text-muted-foreground px-2 pt-2 pb-1 text-[11px] font-medium">
28
+ {s.name}
29
+ </p>
30
+ )}
31
+ {s.items.map((c) => {
32
+ const i = index++;
33
+ const Icon = c.icon as LucideIcon | undefined;
34
+ return (
35
+ <button
36
+ key={c.id}
37
+ type="button"
38
+ role="option"
39
+ aria-selected={i === highlighted}
40
+ data-index={i}
41
+ onMouseMove={() => i !== highlighted && onHover(i)}
42
+ onClick={() => onRun(c)}
43
+ className={cn(
44
+ "flex min-h-11 w-full items-center gap-2.5 rounded-lg px-2 text-left text-sm md:min-h-9",
45
+ i === highlighted && "bg-primary/10",
46
+ c.id.startsWith("see-all:") && "text-muted-foreground"
47
+ )}
48
+ >
49
+ {Icon && (
50
+ <Icon className="text-muted-foreground h-4 w-4 shrink-0" />
51
+ )}
52
+ <span className="min-w-0 flex-1">
53
+ <span className="block truncate">{c.title}</span>
54
+ {c.detail && (
55
+ <span
56
+ className={cn(
57
+ "block truncate text-xs",
58
+ c.status === "waiting"
59
+ ? "text-amber-700 dark:text-amber-400"
60
+ : "text-muted-foreground"
61
+ )}
62
+ >
63
+ {c.detail}
64
+ </span>
65
+ )}
66
+ </span>
67
+ {c.hint && (
68
+ <kbd className="text-muted-foreground hidden shrink-0 font-mono text-[11px] sm:inline">
69
+ {c.hint}
70
+ </kbd>
71
+ )}
72
+ </button>
73
+ );
74
+ })}
75
+ </div>
76
+ ))}
77
+ </>
78
+ );
79
+ }
@@ -0,0 +1,51 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import { PALETTE_TABS, type PaletteTab } from "@/lib/palette/registry";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ // The palette's tabs. Tab and Shift-Tab in the input move between them, so
8
+ // these keep out of the focus order and never take focus from it.
9
+ export function PaletteTabs({
10
+ tab,
11
+ onSelect,
12
+ }: {
13
+ tab: PaletteTab;
14
+ onSelect: (tab: PaletteTab) => void;
15
+ }) {
16
+ const ref = useRef<HTMLDivElement>(null);
17
+ useEffect(() => {
18
+ ref.current
19
+ ?.querySelector('[aria-selected="true"]')
20
+ ?.scrollIntoView({ block: "nearest", inline: "nearest" });
21
+ }, [tab]);
22
+ return (
23
+ <div
24
+ ref={ref}
25
+ role="tablist"
26
+ aria-label="Palette sections"
27
+ className="border-foreground/[0.06] flex shrink-0 scrollbar-none gap-1 overflow-x-auto border-b px-2 py-1.5"
28
+ >
29
+ {PALETTE_TABS.map((t) => (
30
+ <button
31
+ key={t.id}
32
+ type="button"
33
+ role="tab"
34
+ id={`palette-tab-${t.id}`}
35
+ aria-selected={t.id === tab}
36
+ tabIndex={-1}
37
+ onMouseDown={(e) => e.preventDefault()}
38
+ onClick={() => onSelect(t.id)}
39
+ className={cn(
40
+ "flex min-h-11 shrink-0 items-center rounded-lg px-3 text-sm whitespace-nowrap md:min-h-8 md:px-2.5 md:text-xs",
41
+ t.id === tab
42
+ ? "bg-primary/10 text-foreground font-medium"
43
+ : "text-muted-foreground hover:text-foreground"
44
+ )}
45
+ >
46
+ {t.label}
47
+ </button>
48
+ ))}
49
+ </div>
50
+ );
51
+ }
@@ -8,14 +8,16 @@ import { MIN_QUERY, useConversationSearch } from "@/data/search";
8
8
  import { useDebouncedValue } from "@/hooks/useDebouncedValue";
9
9
  import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
10
10
  import { useViewport } from "@/hooks/useViewport";
11
+ import { cycleTab } from "@/lib/palette/tabs";
11
12
  import { paletteActions, paletteUi } from "@/stores/palette";
13
+ import { PaletteTabs } from "../PaletteTabs";
12
14
  import { NoResults, Recent, Skeleton } from "./EmptyState";
13
15
  import { Preview } from "./Preview";
14
16
  import { ResultList, flatten } from "./ResultList";
15
17
  import { loadRecent, saveRecent, withRecent } from "./recent";
16
18
  import { useOpenHit } from "./useOpenHit";
17
19
 
18
- // ⌘K's second mode: what was said in any chat, found as you type. Results on
20
+ // ⌘K's Conversations tab: what was said in any chat, found as you type. Results on
19
21
  // the left, the selected message in its conversation on the right; a phone
20
22
  // shows the list, and a tap shows the message.
21
23
  export function ConversationSearch() {
@@ -97,7 +99,10 @@ export function ConversationSearch() {
97
99
  openHit(highlighted);
98
100
  } else if (e.key === "Tab") {
99
101
  e.preventDefault();
100
- paletteActions.setMode("commands", text);
102
+ paletteActions.setTab(
103
+ cycleTab("conversations", e.shiftKey ? -1 : 1),
104
+ text
105
+ );
101
106
  }
102
107
  }}
103
108
  placeholder="Search every conversation…"
@@ -111,8 +116,12 @@ export function ConversationSearch() {
111
116
  esc
112
117
  </kbd>
113
118
  </div>
119
+ <PaletteTabs
120
+ tab="conversations"
121
+ onSelect={(tab) => paletteActions.setTab(tab, text)}
122
+ />
114
123
  {workspace && (
115
- <div className="flex items-center gap-2 px-3 pb-2">
124
+ <div className="flex items-center gap-2 px-3 py-2">
116
125
  <div className="flex overflow-hidden rounded-md border text-xs">
117
126
  {(
118
127
  [
@@ -222,19 +231,12 @@ export function ConversationSearch() {
222
231
  <kbd className="font-mono">↵</kbd> open
223
232
  </span>
224
233
  <span>
225
- <kbd className="font-mono">tab</kbd> commands
234
+ <kbd className="font-mono">tab</kbd> next tab
226
235
  </span>
227
236
  <span>
228
237
  <kbd className="font-mono">esc</kbd> close
229
238
  </span>
230
239
  </span>
231
- <button
232
- type="button"
233
- onClick={() => paletteActions.setMode("commands", text)}
234
- className="h-8 sm:hidden"
235
- >
236
- Commands
237
- </button>
238
240
  <span className="ml-auto font-mono">
239
241
  {searching && data
240
242
  ? `${groups.length} ${groups.length === 1 ? "chat" : "chats"} · ${data.tookMs} ms`
@@ -23,6 +23,7 @@ import {
23
23
  Scale,
24
24
  } from "lucide-react";
25
25
  import type { Session } from "@/lib/db";
26
+ import type { SessionStatus } from "@/components/views/types";
26
27
  import type { PaletteCommand } from "@/lib/palette/registry";
27
28
  import { useWorkspacesQuery } from "@/data/workspaces";
28
29
  import { useRecipesQuery } from "@/data/recipes";
@@ -57,13 +58,25 @@ function switchMode(current: string | undefined, to: "light" | "dark"): string {
57
58
  }
58
59
  }
59
60
 
61
+ const STATE: Record<string, { status: "running" | "waiting"; detail: string }> =
62
+ {
63
+ waiting: { status: "waiting", detail: "Waiting on you" },
64
+ running: { status: "running", detail: "Working" },
65
+ };
66
+
67
+ function sessionState(s: SessionStatus | undefined) {
68
+ return s?.need || (s?.asks ?? 0) > 0 ? STATE.waiting : STATE[s?.status ?? ""];
69
+ }
70
+
60
71
  // The palette's app-wide commands and its session search.
61
72
  export function useAppCommands({
62
73
  sessions,
74
+ statuses,
63
75
  onSelectSession,
64
76
  onSearchCode,
65
77
  }: {
66
78
  sessions: Session[];
79
+ statuses: Record<string, SessionStatus>;
67
80
  onSelectSession: (session: Session) => void;
68
81
  onSearchCode: () => void;
69
82
  }) {
@@ -260,6 +273,7 @@ export function useAppCommands({
260
273
  group: "Sessions",
261
274
  keywords: [s.working_directory, s.branch_name ?? ""].filter(Boolean),
262
275
  icon: MessageSquare,
276
+ ...sessionState(statuses[s.id]),
263
277
  run: () => onSelectSession(s),
264
278
  }))
265
279
  );
@@ -0,0 +1,55 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import { MessagesSquare, Search } from "lucide-react";
5
+ import type { PaletteCommand } from "@/lib/palette/registry";
6
+ import { MIN_QUERY, useConversationSearch } from "@/data/search";
7
+ import { useDebouncedValue } from "@/hooks/useDebouncedValue";
8
+ import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
9
+ import { paletteActions } from "@/stores/palette";
10
+ import { plain } from "./conversations/format";
11
+ import { useOpenHit } from "./conversations/useOpenHit";
12
+
13
+ // Conversation matches offered last in All.
14
+ const CONVERSATIONS = 3;
15
+
16
+ // The few conversations a query finds, then a way into the full search.
17
+ export function useConversationRows(
18
+ query: string,
19
+ enabled: boolean
20
+ ): PaletteCommand[] {
21
+ const { workspace } = useSelectedWorkspace();
22
+ const debounced = useDebouncedValue(query.trim(), 150);
23
+ const openHit = useOpenHit();
24
+ const { data: found } = useConversationSearch(
25
+ debounced,
26
+ workspace?.id ?? "all",
27
+ { limit: CONVERSATIONS, enabled }
28
+ );
29
+ return useMemo(() => {
30
+ const q = query.trim();
31
+ if (!enabled || q.length < MIN_QUERY || q !== debounced) return [];
32
+ const hits = (found?.groups ?? []).flatMap((group) =>
33
+ group.hits.slice(0, 1).map((hit) => ({
34
+ id: `conversation:${group.sessionId}:${hit.itemId}`,
35
+ title: group.name,
36
+ detail: plain(hit.snippet),
37
+ group: "Conversations",
38
+ icon: MessagesSquare,
39
+ run: () => openHit(group, hit),
40
+ keepOpen: true,
41
+ }))
42
+ );
43
+ return [
44
+ ...hits,
45
+ {
46
+ id: "conversation:all",
47
+ title: `Search all conversations for “${q}”`,
48
+ group: "Conversations",
49
+ icon: Search,
50
+ keepOpen: true,
51
+ run: () => paletteActions.setTab("conversations", q),
52
+ },
53
+ ];
54
+ }, [found, query, debounced, enabled, openHit]);
55
+ }
@@ -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 &amp; 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&apos;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
+ }