@agent-native/dispatch 0.8.19 → 0.8.23
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/README.md +22 -2
- package/dist/actions/start-workspace-app-creation.js +1 -1
- package/dist/actions/start-workspace-app-creation.js.map +1 -1
- package/dist/components/create-app-popover.js +3 -3
- package/dist/components/create-app-popover.js.map +1 -1
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +66 -8
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/routes/pages/apps.d.ts.map +1 -1
- package/dist/routes/pages/apps.js +3 -1
- package/dist/routes/pages/apps.js.map +1 -1
- package/dist/routes/pages/chat.d.ts +21 -2
- package/dist/routes/pages/chat.d.ts.map +1 -1
- package/dist/routes/pages/chat.js +12 -3
- package/dist/routes/pages/chat.js.map +1 -1
- package/dist/routes/pages/overview.d.ts +21 -2
- package/dist/routes/pages/overview.d.ts.map +1 -1
- package/dist/routes/pages/overview.js +13 -4
- package/dist/routes/pages/overview.js.map +1 -1
- package/dist/server/lib/app-creation-store.d.ts.map +1 -1
- package/dist/server/lib/app-creation-store.js +18 -0
- package/dist/server/lib/app-creation-store.js.map +1 -1
- package/dist/server/lib/dispatch-integrations.d.ts.map +1 -1
- package/dist/server/lib/dispatch-integrations.js +27 -3
- package/dist/server/lib/dispatch-integrations.js.map +1 -1
- package/dist/server/lib/thread-link-preview.d.ts +24 -0
- package/dist/server/lib/thread-link-preview.d.ts.map +1 -0
- package/dist/server/lib/thread-link-preview.js +176 -0
- package/dist/server/lib/thread-link-preview.js.map +1 -0
- package/dist/server/plugins/agent-chat.js +1 -1
- package/dist/server/plugins/agent-chat.js.map +1 -1
- package/dist/server/plugins/integrations.js +2 -2
- package/dist/server/plugins/integrations.js.map +1 -1
- package/package.json +1 -1
- package/src/actions/start-workspace-app-creation.ts +1 -1
- package/src/components/create-app-popover.tsx +3 -3
- package/src/components/layout/Layout.tsx +133 -14
- package/src/routes/pages/apps.tsx +4 -0
- package/src/routes/pages/chat.tsx +20 -3
- package/src/routes/pages/overview.tsx +21 -8
- package/src/server/lib/app-creation-store.spec.ts +15 -0
- package/src/server/lib/app-creation-store.ts +18 -0
- package/src/server/lib/dispatch-integrations.spec.ts +69 -0
- package/src/server/lib/dispatch-integrations.ts +26 -3
- package/src/server/lib/thread-link-preview.spec.ts +129 -0
- package/src/server/lib/thread-link-preview.ts +187 -0
- package/src/server/plugins/agent-chat.ts +1 -1
- package/src/server/plugins/integrations.ts +2 -2
- package/src/styles/dispatch.css +4 -4
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
useEffect,
|
|
3
3
|
useMemo,
|
|
4
|
+
useRef,
|
|
4
5
|
useState,
|
|
5
6
|
type ComponentType,
|
|
7
|
+
type FormEvent,
|
|
6
8
|
type ReactNode,
|
|
7
9
|
} from "react";
|
|
8
10
|
import { NavLink, useLocation, useNavigate } from "react-router";
|
|
@@ -25,6 +27,8 @@ import {
|
|
|
25
27
|
IconBrandTelegram,
|
|
26
28
|
IconKey,
|
|
27
29
|
IconChevronDown,
|
|
30
|
+
IconDots,
|
|
31
|
+
IconEdit,
|
|
28
32
|
IconLayersSubtract,
|
|
29
33
|
IconMessageQuestion,
|
|
30
34
|
IconMessages,
|
|
@@ -38,6 +42,13 @@ import {
|
|
|
38
42
|
IconUsersGroup,
|
|
39
43
|
} from "@tabler/icons-react";
|
|
40
44
|
import { cn } from "@/lib/utils";
|
|
45
|
+
import {
|
|
46
|
+
DropdownMenu,
|
|
47
|
+
DropdownMenuContent,
|
|
48
|
+
DropdownMenuItem,
|
|
49
|
+
DropdownMenuTrigger,
|
|
50
|
+
} from "@/components/ui/dropdown-menu";
|
|
51
|
+
import { Input } from "@/components/ui/input";
|
|
41
52
|
import {
|
|
42
53
|
Sheet,
|
|
43
54
|
SheetContent,
|
|
@@ -290,6 +301,14 @@ function threadTitle(thread: ChatThreadSummary) {
|
|
|
290
301
|
return thread.title || thread.preview || "New chat";
|
|
291
302
|
}
|
|
292
303
|
|
|
304
|
+
function threadUpdatedAt(thread: ChatThreadSummary) {
|
|
305
|
+
return Number.isFinite(thread.updatedAt)
|
|
306
|
+
? thread.updatedAt
|
|
307
|
+
: Number.isFinite(thread.createdAt)
|
|
308
|
+
? thread.createdAt
|
|
309
|
+
: 0;
|
|
310
|
+
}
|
|
311
|
+
|
|
293
312
|
function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
|
|
294
313
|
const navigate = useNavigate();
|
|
295
314
|
const {
|
|
@@ -297,8 +316,13 @@ function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
|
|
|
297
316
|
activeThreadId,
|
|
298
317
|
createThread,
|
|
299
318
|
switchThread,
|
|
319
|
+
renameThread,
|
|
300
320
|
refreshThreads,
|
|
301
321
|
} = useChatThreads(undefined, undefined, undefined, { autoCreate: false });
|
|
322
|
+
const [renamingThreadId, setRenamingThreadId] = useState<string | null>(null);
|
|
323
|
+
const [renameDraft, setRenameDraft] = useState("");
|
|
324
|
+
const renameInputRef = useRef<HTMLInputElement | null>(null);
|
|
325
|
+
const committingRenameRef = useRef(false);
|
|
302
326
|
|
|
303
327
|
const visibleThreads = useMemo(
|
|
304
328
|
() =>
|
|
@@ -306,7 +330,7 @@ function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
|
|
|
306
330
|
.filter(
|
|
307
331
|
(thread) => thread.messageCount > 0 || thread.id === activeThreadId,
|
|
308
332
|
)
|
|
309
|
-
.sort((a, b) => b
|
|
333
|
+
.sort((a, b) => threadUpdatedAt(b) - threadUpdatedAt(a))
|
|
310
334
|
.slice(0, 8),
|
|
311
335
|
[activeThreadId, threads],
|
|
312
336
|
);
|
|
@@ -330,6 +354,14 @@ function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
|
|
|
330
354
|
};
|
|
331
355
|
}, [refreshThreads]);
|
|
332
356
|
|
|
357
|
+
useEffect(() => {
|
|
358
|
+
if (!renamingThreadId) return;
|
|
359
|
+
requestAnimationFrame(() => {
|
|
360
|
+
renameInputRef.current?.focus();
|
|
361
|
+
renameInputRef.current?.select();
|
|
362
|
+
});
|
|
363
|
+
}, [renamingThreadId]);
|
|
364
|
+
|
|
333
365
|
function openThread(threadId: string, options?: { isNew?: boolean }) {
|
|
334
366
|
switchThread(threadId);
|
|
335
367
|
navigate(dispatchNavLinkTarget("/chat"));
|
|
@@ -348,6 +380,35 @@ function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
|
|
|
348
380
|
if (threadId) openThread(threadId, { isNew: true });
|
|
349
381
|
}
|
|
350
382
|
|
|
383
|
+
function startRenameThread(thread: ChatThreadSummary) {
|
|
384
|
+
committingRenameRef.current = false;
|
|
385
|
+
setRenameDraft(threadTitle(thread));
|
|
386
|
+
setRenamingThreadId(thread.id);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function cancelRenameThread() {
|
|
390
|
+
committingRenameRef.current = true;
|
|
391
|
+
setRenamingThreadId(null);
|
|
392
|
+
setRenameDraft("");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function commitRenameThread() {
|
|
396
|
+
if (committingRenameRef.current) return;
|
|
397
|
+
const threadId = renamingThreadId;
|
|
398
|
+
const title = renameDraft.trim();
|
|
399
|
+
if (!threadId) return;
|
|
400
|
+
committingRenameRef.current = true;
|
|
401
|
+
setRenamingThreadId(null);
|
|
402
|
+
setRenameDraft("");
|
|
403
|
+
if (title) await renameThread(threadId, title);
|
|
404
|
+
committingRenameRef.current = false;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function handleRenameSubmit(event: FormEvent<HTMLFormElement>) {
|
|
408
|
+
event.preventDefault();
|
|
409
|
+
void commitRenameThread();
|
|
410
|
+
}
|
|
411
|
+
|
|
351
412
|
return (
|
|
352
413
|
<div className="mt-2 border-l border-sidebar-border/70 pl-3">
|
|
353
414
|
<div className="mb-1 flex h-7 items-center gap-2 pr-1">
|
|
@@ -372,25 +433,82 @@ function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
|
|
|
372
433
|
{visibleThreads.length > 0 ? (
|
|
373
434
|
visibleThreads.map((thread) => {
|
|
374
435
|
const isActive = thread.id === activeThreadId;
|
|
436
|
+
const isRenaming = thread.id === renamingThreadId;
|
|
375
437
|
return (
|
|
376
|
-
<
|
|
438
|
+
<div
|
|
377
439
|
key={thread.id}
|
|
378
|
-
type="button"
|
|
379
|
-
onClick={() => openThread(thread.id)}
|
|
380
440
|
className={cn(
|
|
381
|
-
"flex h-8 min-w-0
|
|
441
|
+
"group flex h-8 min-w-0 items-center rounded-md text-sm transition-colors",
|
|
382
442
|
isActive
|
|
383
443
|
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
|
384
444
|
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/65 hover:text-sidebar-accent-foreground",
|
|
385
445
|
)}
|
|
386
446
|
>
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
447
|
+
{isRenaming ? (
|
|
448
|
+
<form
|
|
449
|
+
onSubmit={handleRenameSubmit}
|
|
450
|
+
className="flex h-full min-w-0 flex-1 items-center px-1.5"
|
|
451
|
+
>
|
|
452
|
+
<Input
|
|
453
|
+
ref={renameInputRef}
|
|
454
|
+
value={renameDraft}
|
|
455
|
+
onChange={(event) => setRenameDraft(event.target.value)}
|
|
456
|
+
onBlur={() => void commitRenameThread()}
|
|
457
|
+
onKeyDown={(event) => {
|
|
458
|
+
if (event.key === "Escape") {
|
|
459
|
+
event.preventDefault();
|
|
460
|
+
cancelRenameThread();
|
|
461
|
+
}
|
|
462
|
+
}}
|
|
463
|
+
maxLength={160}
|
|
464
|
+
aria-label={`Rename ${threadTitle(thread)}`}
|
|
465
|
+
className="h-6 min-w-0 rounded-sm border-sidebar-border bg-background px-1.5 text-xs"
|
|
466
|
+
/>
|
|
467
|
+
</form>
|
|
468
|
+
) : (
|
|
469
|
+
<>
|
|
470
|
+
<button
|
|
471
|
+
type="button"
|
|
472
|
+
onClick={() => openThread(thread.id)}
|
|
473
|
+
className="flex h-full min-w-0 flex-1 cursor-pointer items-center px-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
474
|
+
>
|
|
475
|
+
<span className="min-w-0 flex-1 truncate">
|
|
476
|
+
{threadTitle(thread)}
|
|
477
|
+
</span>
|
|
478
|
+
</button>
|
|
479
|
+
<div className="relative flex size-7 shrink-0 items-center justify-end pr-1">
|
|
480
|
+
<span className="text-[11px] text-sidebar-foreground/50 transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
|
|
481
|
+
{isActive
|
|
482
|
+
? ""
|
|
483
|
+
: formatThreadAge(threadUpdatedAt(thread))}
|
|
484
|
+
</span>
|
|
485
|
+
<DropdownMenu>
|
|
486
|
+
<DropdownMenuTrigger asChild>
|
|
487
|
+
<button
|
|
488
|
+
type="button"
|
|
489
|
+
aria-label={`Chat options for ${threadTitle(thread)}`}
|
|
490
|
+
className="absolute right-1 flex size-6 cursor-pointer items-center justify-center rounded-md text-sidebar-foreground/65 opacity-0 transition-opacity hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100 group-focus-within:opacity-100 data-[state=open]:opacity-100"
|
|
491
|
+
>
|
|
492
|
+
<IconDots className="size-4" />
|
|
493
|
+
</button>
|
|
494
|
+
</DropdownMenuTrigger>
|
|
495
|
+
<DropdownMenuContent
|
|
496
|
+
align="end"
|
|
497
|
+
side="right"
|
|
498
|
+
sideOffset={6}
|
|
499
|
+
>
|
|
500
|
+
<DropdownMenuItem
|
|
501
|
+
onSelect={() => startRenameThread(thread)}
|
|
502
|
+
>
|
|
503
|
+
<IconEdit className="size-4" />
|
|
504
|
+
Rename chat
|
|
505
|
+
</DropdownMenuItem>
|
|
506
|
+
</DropdownMenuContent>
|
|
507
|
+
</DropdownMenu>
|
|
508
|
+
</div>
|
|
509
|
+
</>
|
|
510
|
+
)}
|
|
511
|
+
</div>
|
|
394
512
|
);
|
|
395
513
|
})
|
|
396
514
|
) : (
|
|
@@ -438,13 +556,14 @@ export function NavContent({
|
|
|
438
556
|
|
|
439
557
|
const renderNavItem = (item: DispatchNavItem) => {
|
|
440
558
|
const Icon = item.icon;
|
|
559
|
+
const itemMatchesLocalPath = navItemMatchesPath(item, localPathname);
|
|
441
560
|
return (
|
|
442
561
|
<li key={item.id}>
|
|
443
562
|
<NavLink
|
|
444
563
|
to={dispatchNavLinkTarget(item.to)}
|
|
445
564
|
onClick={onNavigate}
|
|
446
565
|
className={({ isActive }) => {
|
|
447
|
-
const active = isActive ||
|
|
566
|
+
const active = isActive || itemMatchesLocalPath;
|
|
448
567
|
return cn(
|
|
449
568
|
"flex h-8 w-full items-center gap-2 rounded-md px-2 text-sm",
|
|
450
569
|
active
|
|
@@ -460,7 +579,7 @@ export function NavContent({
|
|
|
460
579
|
)}
|
|
461
580
|
<span className="truncate">{item.label}</span>
|
|
462
581
|
</NavLink>
|
|
463
|
-
{item.id === "chat" ? (
|
|
582
|
+
{item.id === "chat" && itemMatchesLocalPath ? (
|
|
464
583
|
<DispatchChatsSection onNavigate={onNavigate} />
|
|
465
584
|
) : null}
|
|
466
585
|
</li>
|
|
@@ -2,6 +2,7 @@ import { useState } from "react";
|
|
|
2
2
|
import { useActionMutation, useActionQuery } from "@agent-native/core/client";
|
|
3
3
|
import {
|
|
4
4
|
IconApps,
|
|
5
|
+
IconBrain,
|
|
5
6
|
IconBrush,
|
|
6
7
|
IconCalendarMonth,
|
|
7
8
|
IconChartBar,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
IconFileText,
|
|
12
13
|
IconLoader2,
|
|
13
14
|
IconMail,
|
|
15
|
+
IconPhoto,
|
|
14
16
|
IconPlus,
|
|
15
17
|
IconPresentation,
|
|
16
18
|
IconScreenShare,
|
|
@@ -58,6 +60,8 @@ const TEMPLATE_ICONS: Record<string, typeof IconMail> = {
|
|
|
58
60
|
FileText: IconFileText,
|
|
59
61
|
Presentation: IconPresentation,
|
|
60
62
|
ScreenShare: IconScreenShare,
|
|
63
|
+
Brain: IconBrain,
|
|
64
|
+
Photo: IconPhoto,
|
|
61
65
|
ChartBar: IconChartBar,
|
|
62
66
|
ClipboardList: IconClipboardList,
|
|
63
67
|
Brush: IconBrush,
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
useLocation,
|
|
4
|
+
useNavigate,
|
|
5
|
+
type LoaderFunctionArgs,
|
|
6
|
+
} from "react-router";
|
|
3
7
|
import { AgentChatSurface } from "@agent-native/core/client";
|
|
4
8
|
import { submitOverviewPrompt } from "@/lib/overview-chat";
|
|
9
|
+
import {
|
|
10
|
+
buildThreadLinkPreviewMeta,
|
|
11
|
+
loadThreadLinkPreview,
|
|
12
|
+
} from "@/server/lib/thread-link-preview";
|
|
5
13
|
|
|
6
14
|
interface DispatchChatLocationState {
|
|
7
15
|
dispatchPrompt?: {
|
|
@@ -15,8 +23,17 @@ interface DispatchChatLocationState {
|
|
|
15
23
|
};
|
|
16
24
|
}
|
|
17
25
|
|
|
18
|
-
export function
|
|
19
|
-
|
|
26
|
+
export async function loader({ request }: LoaderFunctionArgs) {
|
|
27
|
+
const threadId = new URL(request.url).searchParams.get("thread");
|
|
28
|
+
return {
|
|
29
|
+
threadPreview: await loadThreadLinkPreview(threadId),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function meta({ data }: { data?: Awaited<ReturnType<typeof loader>> }) {
|
|
34
|
+
return data?.threadPreview
|
|
35
|
+
? buildThreadLinkPreviewMeta(data.threadPreview)
|
|
36
|
+
: [{ title: "Chat — Dispatch" }];
|
|
20
37
|
}
|
|
21
38
|
|
|
22
39
|
export default function ChatRoute() {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState } from "react";
|
|
2
|
-
import { Link, useNavigate } from "react-router";
|
|
2
|
+
import { Link, useNavigate, type LoaderFunctionArgs } from "react-router";
|
|
3
3
|
import {
|
|
4
4
|
PromptComposer,
|
|
5
5
|
useActionQuery,
|
|
@@ -34,6 +34,10 @@ import {
|
|
|
34
34
|
TooltipTrigger,
|
|
35
35
|
} from "@/components/ui/tooltip";
|
|
36
36
|
import { submitOverviewPrompt } from "@/lib/overview-chat";
|
|
37
|
+
import {
|
|
38
|
+
buildThreadLinkPreviewMeta,
|
|
39
|
+
loadThreadLinkPreview,
|
|
40
|
+
} from "@/server/lib/thread-link-preview";
|
|
37
41
|
import type { WorkspaceAppSummary } from "@/lib/workspace-apps";
|
|
38
42
|
|
|
39
43
|
interface IntegrationStatus {
|
|
@@ -395,18 +399,18 @@ function StatCard({
|
|
|
395
399
|
cta?: React.ReactNode;
|
|
396
400
|
}) {
|
|
397
401
|
return (
|
|
398
|
-
<div className="rounded-2xl border bg-card p-5">
|
|
402
|
+
<div className="min-w-0 rounded-2xl border bg-card p-5">
|
|
399
403
|
<div className="flex items-start justify-between gap-3">
|
|
400
404
|
<div className="min-w-0">
|
|
401
|
-
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
|
402
|
-
<span>{label}</span>
|
|
405
|
+
<div className="flex items-center gap-1.5 text-sm font-medium leading-snug text-foreground">
|
|
406
|
+
<span className="min-w-0">{label}</span>
|
|
403
407
|
<HelpTooltip content={help} />
|
|
404
408
|
</div>
|
|
405
409
|
<div className="mt-3 text-3xl font-semibold text-foreground">
|
|
406
410
|
{value}
|
|
407
411
|
</div>
|
|
408
412
|
</div>
|
|
409
|
-
<div className="rounded-xl border bg-muted/30 p-3 text-muted-foreground">
|
|
413
|
+
<div className="shrink-0 rounded-xl border bg-muted/30 p-3 text-muted-foreground">
|
|
410
414
|
<Icon size={18} />
|
|
411
415
|
</div>
|
|
412
416
|
</div>
|
|
@@ -471,8 +475,17 @@ function StepRow({ step }: { step: ChecklistStep }) {
|
|
|
471
475
|
);
|
|
472
476
|
}
|
|
473
477
|
|
|
474
|
-
export function
|
|
475
|
-
|
|
478
|
+
export async function loader({ request }: LoaderFunctionArgs) {
|
|
479
|
+
const threadId = new URL(request.url).searchParams.get("thread");
|
|
480
|
+
return {
|
|
481
|
+
threadPreview: await loadThreadLinkPreview(threadId),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function meta({ data }: { data?: Awaited<ReturnType<typeof loader>> }) {
|
|
486
|
+
return data?.threadPreview
|
|
487
|
+
? buildThreadLinkPreviewMeta(data.threadPreview)
|
|
488
|
+
: [{ title: "Overview — Dispatch" }];
|
|
476
489
|
}
|
|
477
490
|
|
|
478
491
|
export default function OverviewRoute() {
|
|
@@ -636,7 +649,7 @@ export default function OverviewRoute() {
|
|
|
636
649
|
<IconActivity size={16} className="text-muted-foreground" />
|
|
637
650
|
<h2 className="text-sm font-semibold text-foreground">At a glance</h2>
|
|
638
651
|
</div>
|
|
639
|
-
<div className="grid
|
|
652
|
+
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,13rem),1fr))] gap-4">
|
|
640
653
|
<StatCard
|
|
641
654
|
label="Vault secrets"
|
|
642
655
|
help="Credentials stored in the workspace vault."
|
|
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
import { runWithRequestContext } from "@agent-native/core/server";
|
|
3
3
|
import {
|
|
4
4
|
generateWorkspaceAppDescription,
|
|
5
|
+
listAvailableWorkspaceTemplates,
|
|
5
6
|
listWorkspaceApps,
|
|
6
7
|
updateWorkspaceAppMetadata,
|
|
7
8
|
} from "./app-creation-store.js";
|
|
@@ -347,4 +348,18 @@ describe("listWorkspaceApps", () => {
|
|
|
347
348
|
),
|
|
348
349
|
).toBe("Tracks customer onboarding risks and handoffs.");
|
|
349
350
|
});
|
|
351
|
+
|
|
352
|
+
it("offers Brain and Assets as workspace template tiles", async () => {
|
|
353
|
+
stubNoPendingContext();
|
|
354
|
+
stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
|
|
355
|
+
|
|
356
|
+
const templates = await runWithRequestContext(
|
|
357
|
+
{ userEmail: "dev@example.test" },
|
|
358
|
+
() => listAvailableWorkspaceTemplates(),
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
expect(templates.map((template) => template.name)).toEqual(
|
|
362
|
+
expect.arrayContaining(["brain", "assets"]),
|
|
363
|
+
);
|
|
364
|
+
});
|
|
350
365
|
});
|
|
@@ -1293,6 +1293,24 @@ const ADDABLE_TEMPLATES: AvailableWorkspaceTemplate[] = [
|
|
|
1293
1293
|
colorRgb: "98 93 245",
|
|
1294
1294
|
core: true,
|
|
1295
1295
|
},
|
|
1296
|
+
{
|
|
1297
|
+
name: "brain",
|
|
1298
|
+
label: "Brain",
|
|
1299
|
+
hint: "Cited company knowledge from Slack, meetings, transcripts, and decisions",
|
|
1300
|
+
icon: "Brain",
|
|
1301
|
+
color: "#8B5CF6",
|
|
1302
|
+
colorRgb: "139 92 246",
|
|
1303
|
+
core: true,
|
|
1304
|
+
},
|
|
1305
|
+
{
|
|
1306
|
+
name: "assets",
|
|
1307
|
+
label: "Assets",
|
|
1308
|
+
hint: "Upload, organize, search, and generate on-brand images and videos",
|
|
1309
|
+
icon: "Photo",
|
|
1310
|
+
color: "#0F766E",
|
|
1311
|
+
colorRgb: "15 118 110",
|
|
1312
|
+
core: true,
|
|
1313
|
+
},
|
|
1296
1314
|
{
|
|
1297
1315
|
name: "analytics",
|
|
1298
1316
|
label: "Analytics",
|
|
@@ -38,6 +38,21 @@ function slackIncoming(
|
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function emailIncoming(
|
|
42
|
+
overrides: Partial<IncomingMessage> = {},
|
|
43
|
+
): IncomingMessage {
|
|
44
|
+
return {
|
|
45
|
+
platform: "email",
|
|
46
|
+
externalThreadId: "victim@member.test::<root@member.test>",
|
|
47
|
+
text: "transfer everything",
|
|
48
|
+
senderId: "victim@member.test",
|
|
49
|
+
senderName: "Victim",
|
|
50
|
+
platformContext: { from: "victim@member.test" },
|
|
51
|
+
timestamp: 1,
|
|
52
|
+
...overrides,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
41
56
|
beforeEach(() => {
|
|
42
57
|
mocks.resolveLinkedOwner.mockResolvedValue(null);
|
|
43
58
|
mocks.consumeLinkToken.mockResolvedValue("owner@example.test");
|
|
@@ -113,4 +128,58 @@ describe("resolveDispatchOwner", () => {
|
|
|
113
128
|
"default@example.test",
|
|
114
129
|
);
|
|
115
130
|
});
|
|
131
|
+
|
|
132
|
+
it("does NOT impersonate an org member from an unverified (spoofed) email From", async () => {
|
|
133
|
+
// Attacker spoofs From: victim@member.test, which IS a real org member —
|
|
134
|
+
// but the message is unverified (no DKIM/SPF pass). Must fall through to
|
|
135
|
+
// the synthetic, credential-less owner, NOT the victim's identity.
|
|
136
|
+
mocks.resolveOrgIdForEmail.mockResolvedValue("org_123");
|
|
137
|
+
|
|
138
|
+
const owner = await resolveDispatchOwner(
|
|
139
|
+
emailIncoming({ senderVerified: false }),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
expect(owner).not.toBe("victim@member.test");
|
|
143
|
+
expect(owner).toMatch(/@integration\.local$/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("does NOT impersonate when sender is verified but not an org member", async () => {
|
|
147
|
+
mocks.resolveOrgIdForEmail.mockResolvedValue(null);
|
|
148
|
+
|
|
149
|
+
const owner = await resolveDispatchOwner(
|
|
150
|
+
emailIncoming({
|
|
151
|
+
senderId: "stranger@outside.test",
|
|
152
|
+
platformContext: { from: "stranger@outside.test" },
|
|
153
|
+
senderVerified: true,
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
expect(owner).not.toBe("stranger@outside.test");
|
|
158
|
+
expect(owner).toMatch(/@integration\.local$/);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("uses the email sender as owner when verified AND an org member", async () => {
|
|
162
|
+
mocks.resolveOrgIdForEmail.mockResolvedValue("org_123");
|
|
163
|
+
|
|
164
|
+
await expect(
|
|
165
|
+
resolveDispatchOwner(emailIncoming({ senderVerified: true })),
|
|
166
|
+
).resolves.toBe("victim@member.test");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("honors a linked identity for email regardless of verification", async () => {
|
|
170
|
+
mocks.resolveLinkedOwner.mockResolvedValueOnce("linked@member.test");
|
|
171
|
+
|
|
172
|
+
await expect(
|
|
173
|
+
resolveDispatchOwner(emailIncoming({ senderVerified: false })),
|
|
174
|
+
).resolves.toBe("linked@member.test");
|
|
175
|
+
expect(mocks.resolveOrgIdForEmail).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("restores legacy trust-From behavior under the escape hatch", async () => {
|
|
179
|
+
vi.stubEnv("DISPATCH_TRUST_UNVERIFIED_EMAIL_SENDER", "1");
|
|
180
|
+
|
|
181
|
+
await expect(
|
|
182
|
+
resolveDispatchOwner(emailIncoming({ senderVerified: false })),
|
|
183
|
+
).resolves.toBe("victim@member.test");
|
|
184
|
+
});
|
|
116
185
|
});
|
|
@@ -164,14 +164,37 @@ export async function resolveDispatchOwner(
|
|
|
164
164
|
});
|
|
165
165
|
if (owner) return owner;
|
|
166
166
|
|
|
167
|
-
// For email, the sender's
|
|
168
|
-
//
|
|
167
|
+
// For email, the sender's `From:` address is attacker-settable: SMTP lets
|
|
168
|
+
// anyone claim any From, and our inbound webhook secret only authenticates
|
|
169
|
+
// the provider→app hop, not the original sender. So we must NOT grant a
|
|
170
|
+
// real user's identity (their API keys, org secrets, personal
|
|
171
|
+
// instructions, ownable data) off the bare From. Mirror the Slack gate:
|
|
172
|
+
// only return the sender email as the acting owner when BOTH
|
|
173
|
+
// (a) the message is DKIM/SPF-verified for the From domain, AND
|
|
174
|
+
// (b) that email maps to a real org member.
|
|
175
|
+
// Otherwise fall through to the synthetic, credential-less fallback owner.
|
|
176
|
+
// (A linked identity, handled by resolveLinkedOwner above, remains an
|
|
177
|
+
// always-allowed way to bind an address regardless of verification.)
|
|
178
|
+
//
|
|
179
|
+
// Escape hatch: set DISPATCH_TRUST_UNVERIFIED_EMAIL_SENDER=1 to restore
|
|
180
|
+
// the legacy "trust the From header" behavior. OFF by default; only use
|
|
181
|
+
// this if you fully control the inbound mail path and accept that a
|
|
182
|
+
// spoofed From can act as any org member. See FINDING 3 (inbound-email
|
|
183
|
+
// impersonation) in the webhook security audit.
|
|
169
184
|
if (
|
|
170
185
|
incoming.platform === "email" &&
|
|
171
186
|
incoming.senderId &&
|
|
172
187
|
incoming.senderId.includes("@")
|
|
173
188
|
) {
|
|
174
|
-
|
|
189
|
+
if (process.env.DISPATCH_TRUST_UNVERIFIED_EMAIL_SENDER === "1") {
|
|
190
|
+
return incoming.senderId;
|
|
191
|
+
}
|
|
192
|
+
if (incoming.senderVerified) {
|
|
193
|
+
const orgId = await resolveOrgIdForEmail(incoming.senderId);
|
|
194
|
+
if (orgId) return incoming.senderId;
|
|
195
|
+
}
|
|
196
|
+
// Unverified or not an org member — do not impersonate. Fall through to
|
|
197
|
+
// the synthetic fallback owner below.
|
|
175
198
|
}
|
|
176
199
|
|
|
177
200
|
// Slack gives us a user id in the event payload. Resolve it to a verified
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { ChatThread } from "@agent-native/core/server";
|
|
3
|
+
|
|
4
|
+
const getRequestContextMock = vi.hoisted(() => vi.fn());
|
|
5
|
+
const getThreadMock = vi.hoisted(() => vi.fn());
|
|
6
|
+
|
|
7
|
+
vi.mock("@agent-native/core/server", () => ({
|
|
8
|
+
getRequestContext: getRequestContextMock,
|
|
9
|
+
getThread: getThreadMock,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
extractThreadPreviewImageUrl,
|
|
14
|
+
loadThreadLinkPreview,
|
|
15
|
+
} from "./thread-link-preview";
|
|
16
|
+
|
|
17
|
+
function threadDataWithResult(toolName: string, result: unknown) {
|
|
18
|
+
return JSON.stringify({
|
|
19
|
+
messages: [
|
|
20
|
+
{
|
|
21
|
+
message: {
|
|
22
|
+
role: "assistant",
|
|
23
|
+
content: [
|
|
24
|
+
{
|
|
25
|
+
type: "tool-call",
|
|
26
|
+
toolName,
|
|
27
|
+
result:
|
|
28
|
+
typeof result === "string" ? result : JSON.stringify(result),
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
parentId: null,
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function previewThread(overrides: Partial<ChatThread> = {}): ChatThread {
|
|
39
|
+
return {
|
|
40
|
+
id: "thread-1",
|
|
41
|
+
ownerEmail: "owner@example.test",
|
|
42
|
+
title: "Launch image",
|
|
43
|
+
preview: "Generated a launch image",
|
|
44
|
+
threadData: threadDataWithResult("generate-image", {
|
|
45
|
+
previewUrl: "https://cdn.example.com/generated-social.webp",
|
|
46
|
+
}),
|
|
47
|
+
messageCount: 1,
|
|
48
|
+
createdAt: 1,
|
|
49
|
+
updatedAt: 2,
|
|
50
|
+
scope: null,
|
|
51
|
+
pinnedAt: null,
|
|
52
|
+
archivedAt: null,
|
|
53
|
+
...overrides,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
getRequestContextMock.mockReset();
|
|
59
|
+
getThreadMock.mockReset();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("thread link preview image extraction", () => {
|
|
63
|
+
it("uses generated image preview URLs from generate-image results", () => {
|
|
64
|
+
expect(
|
|
65
|
+
extractThreadPreviewImageUrl(
|
|
66
|
+
threadDataWithResult("generate-image", {
|
|
67
|
+
url: "https://app.example.com/assets/asset/asset-1",
|
|
68
|
+
previewUrl: "https://cdn.example.com/generated-social.webp",
|
|
69
|
+
thumbnailUrl: "https://cdn.example.com/generated-social-thumb.webp",
|
|
70
|
+
}),
|
|
71
|
+
),
|
|
72
|
+
).toBe("https://cdn.example.com/generated-social.webp");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("uses the newest image from batched generation results", () => {
|
|
76
|
+
expect(
|
|
77
|
+
extractThreadPreviewImageUrl(
|
|
78
|
+
threadDataWithResult("generate-image-batch", {
|
|
79
|
+
images: [
|
|
80
|
+
{ previewUrl: "https://cdn.example.com/first.png" },
|
|
81
|
+
{ previewUrl: "https://cdn.example.com/latest.png" },
|
|
82
|
+
],
|
|
83
|
+
}),
|
|
84
|
+
),
|
|
85
|
+
).toBe("https://cdn.example.com/latest.png");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("ignores asset page URLs that are not image media", () => {
|
|
89
|
+
expect(
|
|
90
|
+
extractThreadPreviewImageUrl(
|
|
91
|
+
threadDataWithResult("generate-image", {
|
|
92
|
+
url: "https://app.example.com/assets/asset/asset-1",
|
|
93
|
+
}),
|
|
94
|
+
),
|
|
95
|
+
).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("thread link preview access", () => {
|
|
100
|
+
it("loads preview metadata for the owning user", async () => {
|
|
101
|
+
getRequestContextMock.mockReturnValue({
|
|
102
|
+
userEmail: "owner@example.test",
|
|
103
|
+
});
|
|
104
|
+
getThreadMock.mockResolvedValue(previewThread());
|
|
105
|
+
|
|
106
|
+
await expect(loadThreadLinkPreview(" thread-1 ")).resolves.toEqual({
|
|
107
|
+
title: "Launch image",
|
|
108
|
+
description: "Generated a launch image",
|
|
109
|
+
imageUrl: "https://cdn.example.com/generated-social.webp",
|
|
110
|
+
});
|
|
111
|
+
expect(getThreadMock).toHaveBeenCalledWith("thread-1");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("does not read thread metadata without an authenticated request context", async () => {
|
|
115
|
+
getRequestContextMock.mockReturnValue(undefined);
|
|
116
|
+
|
|
117
|
+
await expect(loadThreadLinkPreview("thread-1")).resolves.toBeNull();
|
|
118
|
+
expect(getThreadMock).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("does not emit another user's thread metadata", async () => {
|
|
122
|
+
getRequestContextMock.mockReturnValue({
|
|
123
|
+
userEmail: "viewer@example.test",
|
|
124
|
+
});
|
|
125
|
+
getThreadMock.mockResolvedValue(previewThread());
|
|
126
|
+
|
|
127
|
+
await expect(loadThreadLinkPreview("thread-1")).resolves.toBeNull();
|
|
128
|
+
});
|
|
129
|
+
});
|