@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.
- package/app/api/live-shares/[id]/route.ts +72 -0
- package/app/api/live-shares/route.test.ts +45 -0
- package/app/api/live-shares/route.ts +40 -0
- package/app/api/sessions/[id]/artifacts/[artifactId]/compare/route.ts +41 -0
- package/app/api/shares/preview/route.ts +6 -1
- package/app/api/shares/route.test.ts +51 -0
- package/app/page.tsx +6 -0
- package/components/Artifacts/ArtifactActions.tsx +27 -3
- package/components/Artifacts/ArtifactPreview.tsx +27 -12
- package/components/Artifacts/Compare/DiffView.tsx +116 -0
- package/components/Artifacts/Compare/SideBySide.tsx +60 -0
- package/components/Artifacts/Compare/VersionPicker.tsx +47 -0
- package/components/Artifacts/Compare/index.tsx +191 -0
- package/components/Chat/ChatPanel.tsx +6 -0
- package/components/Chat/EventRow.tsx +6 -0
- package/components/Chat/Messages.tsx +6 -0
- package/components/Chat/ShareFromHere.tsx +39 -0
- package/components/CommandPalette/CommandPalette.tsx +5 -4
- package/components/CommandPalette/CommandsBody.tsx +97 -131
- package/components/CommandPalette/PaletteRows.tsx +79 -0
- package/components/CommandPalette/PaletteTabs.tsx +51 -0
- package/components/CommandPalette/conversations/ConversationSearch.tsx +13 -11
- package/components/CommandPalette/useAppCommands.ts +14 -0
- package/components/CommandPalette/useConversationRows.ts +55 -0
- package/components/Live/GuestPromptCard.tsx +64 -0
- package/components/Live/InviteDialog.tsx +225 -0
- package/components/Live/LiveChip.tsx +109 -0
- package/components/Live/LivePanel.tsx +81 -0
- package/components/Live/LobbyCard.tsx +64 -0
- package/components/Pane/MobileTabBar.tsx +8 -0
- package/components/Pane/PaneMenu.tsx +10 -0
- package/components/SessionList/SessionRowMenu.tsx +6 -0
- package/components/SessionList/useSessionSwitching.ts +2 -2
- package/components/Share/MessageList.tsx +87 -0
- package/components/Share/RangePicker.tsx +59 -0
- package/components/Share/ShareDialog.tsx +113 -52
- package/components/Shares/SharedLinksPanel.tsx +51 -0
- package/data/live/index.ts +2 -0
- package/data/live/keys.ts +4 -0
- package/data/live/queries.ts +81 -0
- package/data/session-artifacts/keys.ts +2 -0
- package/data/session-artifacts/queries.ts +25 -0
- package/data/shares/queries.ts +11 -0
- package/hooks/usePaletteCommands.ts +4 -1
- package/lib/artifacts/actions.ts +11 -1
- package/lib/artifacts/compare/compareVersions.ts +66 -0
- package/lib/artifacts/compare/diff.ts +178 -0
- package/lib/artifacts/compare/extractText.ts +64 -0
- package/lib/artifacts/compare/index.ts +11 -0
- package/lib/artifacts/compare/types.ts +44 -0
- package/lib/artifacts/match.ts +64 -0
- package/lib/artifacts/store.ts +39 -10
- package/lib/artifacts/tools.ts +9 -4
- package/lib/artifacts/versions.ts +2 -1
- package/lib/chat/events.ts +4 -1
- package/lib/chat/registry.ts +10 -0
- package/lib/connect/control-api.ts +44 -0
- package/lib/db/migrations/20261029T1400_live_shares.ts +42 -0
- package/lib/live/clean.ts +54 -0
- package/lib/live/frames.ts +132 -0
- package/lib/live/inbox.ts +225 -0
- package/lib/live/publisher.ts +84 -0
- package/lib/live/ring.ts +61 -0
- package/lib/live/runtime.ts +51 -0
- package/lib/live/service.ts +318 -0
- package/lib/live/socket.ts +293 -0
- package/lib/live/store.ts +149 -0
- package/lib/live/types.ts +39 -0
- package/lib/palette/registry.ts +71 -17
- package/lib/palette/sections.ts +143 -0
- package/lib/palette/tabs.ts +21 -0
- package/lib/palette/usage.ts +81 -0
- package/lib/security/demo.ts +3 -0
- package/lib/share/connect.ts +24 -0
- package/lib/share/markdown.ts +3 -0
- package/lib/share/range.ts +189 -0
- package/lib/share/redact.ts +23 -1
- package/lib/share/render.tsx +3 -0
- package/lib/share/secret-patterns.ts +77 -0
- package/lib/share/service.ts +64 -19
- package/lib/share/snapshot.ts +39 -2
- package/lib/share/styles.ts +2 -0
- package/lib/share/view.ts +11 -0
- package/lib/sidebar/row-menu.ts +2 -0
- package/package.json +1 -1
- package/server.ts +2 -0
- package/stores/liveUi.ts +17 -0
- package/stores/palette.ts +13 -11
- 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
|
|
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.
|
|
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
|
|
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>
|
|
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 & access to invite people.
|
|
59
|
+
</Notice>
|
|
60
|
+
) : share ? (
|
|
61
|
+
<Link share={share} />
|
|
62
|
+
) : (
|
|
63
|
+
<Create sessionId={sessionId} />
|
|
64
|
+
)}
|
|
65
|
+
</DialogContent>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function Create({ sessionId }: { sessionId: string }) {
|
|
70
|
+
const [expiresIn, setExpiresIn] = useState<LiveExpiry>("1h");
|
|
71
|
+
const create = useCreateLive();
|
|
72
|
+
return (
|
|
73
|
+
<>
|
|
74
|
+
<div className="flex flex-col gap-1.5">
|
|
75
|
+
<span className="text-muted-foreground text-xs font-medium">
|
|
76
|
+
What guests can do
|
|
77
|
+
</span>
|
|
78
|
+
<Segmented
|
|
79
|
+
label="Access"
|
|
80
|
+
value="watch"
|
|
81
|
+
onChange={() => {}}
|
|
82
|
+
options={[
|
|
83
|
+
{ value: "watch", label: "Watch + suggest" },
|
|
84
|
+
{ value: "drive", label: "Drive (later)" },
|
|
85
|
+
]}
|
|
86
|
+
disabled
|
|
87
|
+
/>
|
|
88
|
+
<p className="text-muted-foreground text-xs">
|
|
89
|
+
Driving the agent from a guest link isn't available yet.
|
|
90
|
+
</p>
|
|
91
|
+
</div>
|
|
92
|
+
<div className="flex flex-col gap-1.5">
|
|
93
|
+
<span className="text-muted-foreground text-xs font-medium">
|
|
94
|
+
The link works for
|
|
95
|
+
</span>
|
|
96
|
+
<Segmented
|
|
97
|
+
label="Expiry"
|
|
98
|
+
value={expiresIn}
|
|
99
|
+
onChange={setExpiresIn}
|
|
100
|
+
options={(["1h", "session"] as const).map((v) => ({
|
|
101
|
+
value: v,
|
|
102
|
+
label: EXPIRY_LABEL[v],
|
|
103
|
+
}))}
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
{create.error && (
|
|
107
|
+
<p className="text-destructive text-sm">{create.error.message}</p>
|
|
108
|
+
)}
|
|
109
|
+
<Button
|
|
110
|
+
className="min-h-11"
|
|
111
|
+
disabled={create.isPending}
|
|
112
|
+
onClick={() => create.mutate({ sessionId, expiresIn })}
|
|
113
|
+
>
|
|
114
|
+
<Users className="h-4 w-4" />
|
|
115
|
+
{create.isPending ? "Creating…" : "Create invite link"}
|
|
116
|
+
</Button>
|
|
117
|
+
</>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function Link({ share }: { share: LiveView }) {
|
|
122
|
+
const { copied, copy } = useCopyToClipboard();
|
|
123
|
+
const end = useEndLive(share.id);
|
|
124
|
+
const qr = useQr(share.url);
|
|
125
|
+
const canShare = typeof navigator !== "undefined" && "share" in navigator;
|
|
126
|
+
return (
|
|
127
|
+
<>
|
|
128
|
+
<div className="flex flex-col items-center gap-3">
|
|
129
|
+
{qr ? (
|
|
130
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
131
|
+
<img
|
|
132
|
+
src={qr}
|
|
133
|
+
alt="QR code for the invite link"
|
|
134
|
+
className="size-40 rounded-lg bg-white p-2"
|
|
135
|
+
/>
|
|
136
|
+
) : (
|
|
137
|
+
<Skeleton className="size-40" />
|
|
138
|
+
)}
|
|
139
|
+
<input
|
|
140
|
+
readOnly
|
|
141
|
+
value={share.url}
|
|
142
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
143
|
+
aria-label="Invite link"
|
|
144
|
+
className="bg-muted min-h-11 w-full rounded-lg px-3 font-mono text-xs"
|
|
145
|
+
/>
|
|
146
|
+
</div>
|
|
147
|
+
<div className="flex flex-wrap gap-2">
|
|
148
|
+
<Button
|
|
149
|
+
variant="outline"
|
|
150
|
+
className="min-h-11 flex-1"
|
|
151
|
+
onClick={() => copy(share.url)}
|
|
152
|
+
>
|
|
153
|
+
{copied ? (
|
|
154
|
+
<Check className="h-4 w-4" />
|
|
155
|
+
) : (
|
|
156
|
+
<Copy className="h-4 w-4" />
|
|
157
|
+
)}
|
|
158
|
+
Copy
|
|
159
|
+
</Button>
|
|
160
|
+
{canShare && (
|
|
161
|
+
<Button
|
|
162
|
+
variant="outline"
|
|
163
|
+
className="min-h-11 flex-1"
|
|
164
|
+
onClick={() =>
|
|
165
|
+
navigator
|
|
166
|
+
.share({ title: share.title, url: share.url })
|
|
167
|
+
.catch(() => {})
|
|
168
|
+
}
|
|
169
|
+
>
|
|
170
|
+
<Share className="h-4 w-4" />
|
|
171
|
+
Share
|
|
172
|
+
</Button>
|
|
173
|
+
)}
|
|
174
|
+
<Button variant="outline" className="min-h-11 flex-1" asChild>
|
|
175
|
+
<a href={share.url} target="_blank" rel="noopener noreferrer">
|
|
176
|
+
<ExternalLink className="h-4 w-4" />
|
|
177
|
+
Open
|
|
178
|
+
</a>
|
|
179
|
+
</Button>
|
|
180
|
+
</div>
|
|
181
|
+
<p className="text-muted-foreground text-xs">
|
|
182
|
+
{share.endsWithSession
|
|
183
|
+
? "Works until you end access or the session ends."
|
|
184
|
+
: share.expiresAt
|
|
185
|
+
? `Works until ${new Date(share.expiresAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}.`
|
|
186
|
+
: ""}{" "}
|
|
187
|
+
Requests and suggestions show above the message box.
|
|
188
|
+
</p>
|
|
189
|
+
{end.error && (
|
|
190
|
+
<p className="text-destructive text-sm">{end.error.message}</p>
|
|
191
|
+
)}
|
|
192
|
+
<div className="flex gap-2">
|
|
193
|
+
<Button
|
|
194
|
+
variant="outline"
|
|
195
|
+
className="text-destructive min-h-11 flex-1"
|
|
196
|
+
disabled={end.isPending}
|
|
197
|
+
onClick={() => end.mutate()}
|
|
198
|
+
>
|
|
199
|
+
End access
|
|
200
|
+
</Button>
|
|
201
|
+
<Button className="min-h-11 flex-1" onClick={liveUiActions.close}>
|
|
202
|
+
Done
|
|
203
|
+
</Button>
|
|
204
|
+
</div>
|
|
205
|
+
</>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The code is drawn here, in the browser: the link goes to no outside service.
|
|
210
|
+
function useQr(url: string): string | null {
|
|
211
|
+
const [src, setSrc] = useState<string | null>(null);
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
let live = true;
|
|
214
|
+
import("qrcode")
|
|
215
|
+
.then((m) =>
|
|
216
|
+
m.toDataURL(url, { margin: 0, width: 256, errorCorrectionLevel: "M" })
|
|
217
|
+
)
|
|
218
|
+
.then((data) => live && setSrc(data))
|
|
219
|
+
.catch(() => {});
|
|
220
|
+
return () => {
|
|
221
|
+
live = false;
|
|
222
|
+
};
|
|
223
|
+
}, [url]);
|
|
224
|
+
return src;
|
|
225
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { ChevronDown, Radio, UserMinus } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { StatusPill } from "@/components/ui/status-pill";
|
|
7
|
+
import type { LiveView } from "@/data/live";
|
|
8
|
+
import { cn } from "@/lib/utils";
|
|
9
|
+
|
|
10
|
+
export function presenceText(guests: LiveView["guests"]): string {
|
|
11
|
+
const here = guests.filter((g) => g.status === "admitted" && g.online);
|
|
12
|
+
if (!here.length) return "No one watching yet";
|
|
13
|
+
if (here.length === 1) return `${here[0].name} is watching`;
|
|
14
|
+
return `${here[0].name} +${here.length - 1} watching`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The session's live state: who is watching, with Kick and End access.
|
|
18
|
+
export function LiveChip({
|
|
19
|
+
share,
|
|
20
|
+
busy,
|
|
21
|
+
onKick,
|
|
22
|
+
onEnd,
|
|
23
|
+
onInvite,
|
|
24
|
+
}: {
|
|
25
|
+
share: LiveView;
|
|
26
|
+
busy: boolean;
|
|
27
|
+
onKick: (guestId: string) => void;
|
|
28
|
+
onEnd: () => void;
|
|
29
|
+
onInvite: () => void;
|
|
30
|
+
}) {
|
|
31
|
+
const [open, setOpen] = useState(false);
|
|
32
|
+
const admitted = share.guests.filter((g) => g.status === "admitted");
|
|
33
|
+
return (
|
|
34
|
+
<div className="flex flex-col gap-1">
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
aria-expanded={open}
|
|
38
|
+
onClick={() => setOpen(!open)}
|
|
39
|
+
className="hover:bg-muted/60 flex min-h-11 items-center gap-2 rounded-lg px-2 text-left text-xs"
|
|
40
|
+
>
|
|
41
|
+
<StatusPill tone={share.connected ? "green" : "amber"}>
|
|
42
|
+
<Radio className="h-3 w-3" aria-hidden />
|
|
43
|
+
{share.connected ? "Live" : "Reconnecting"}
|
|
44
|
+
</StatusPill>
|
|
45
|
+
<span className="min-w-0 flex-1 truncate">
|
|
46
|
+
{presenceText(share.guests)}
|
|
47
|
+
</span>
|
|
48
|
+
<ChevronDown
|
|
49
|
+
className={cn(
|
|
50
|
+
"h-3.5 w-3.5 transition-transform",
|
|
51
|
+
open && "rotate-180"
|
|
52
|
+
)}
|
|
53
|
+
aria-hidden
|
|
54
|
+
/>
|
|
55
|
+
</button>
|
|
56
|
+
{open && (
|
|
57
|
+
<div className="bg-muted/60 flex flex-col gap-1 rounded-xl p-2 shadow-sm">
|
|
58
|
+
{share.notice && (
|
|
59
|
+
<p className="text-muted-foreground px-2 text-xs">{share.notice}</p>
|
|
60
|
+
)}
|
|
61
|
+
{admitted.length === 0 && (
|
|
62
|
+
<p className="text-muted-foreground px-2 py-1 text-xs">
|
|
63
|
+
No one has joined yet.
|
|
64
|
+
</p>
|
|
65
|
+
)}
|
|
66
|
+
{admitted.map((g) => (
|
|
67
|
+
<div key={g.id} className="flex min-h-11 items-center gap-2 px-2">
|
|
68
|
+
<span
|
|
69
|
+
className={cn(
|
|
70
|
+
"size-2 shrink-0 rounded-full",
|
|
71
|
+
g.online ? "bg-green-500" : "bg-muted-foreground/40"
|
|
72
|
+
)}
|
|
73
|
+
aria-hidden
|
|
74
|
+
/>
|
|
75
|
+
<span className="min-w-0 flex-1 truncate text-sm">{g.name}</span>
|
|
76
|
+
<Button
|
|
77
|
+
variant="ghost"
|
|
78
|
+
className="min-h-11"
|
|
79
|
+
disabled={busy}
|
|
80
|
+
onClick={() => onKick(g.id)}
|
|
81
|
+
aria-label={`Kick ${g.name}`}
|
|
82
|
+
>
|
|
83
|
+
<UserMinus className="h-4 w-4" />
|
|
84
|
+
Kick
|
|
85
|
+
</Button>
|
|
86
|
+
</div>
|
|
87
|
+
))}
|
|
88
|
+
<div className="flex gap-2 pt-1">
|
|
89
|
+
<Button
|
|
90
|
+
variant="outline"
|
|
91
|
+
className="min-h-11 flex-1"
|
|
92
|
+
onClick={onInvite}
|
|
93
|
+
>
|
|
94
|
+
Invite link
|
|
95
|
+
</Button>
|
|
96
|
+
<Button
|
|
97
|
+
variant="outline"
|
|
98
|
+
className="text-destructive min-h-11 flex-1"
|
|
99
|
+
disabled={busy}
|
|
100
|
+
onClick={onEnd}
|
|
101
|
+
>
|
|
102
|
+
End access
|
|
103
|
+
</Button>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|