@agent-native/dispatch 0.20.3 → 0.21.0

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 (66) hide show
  1. package/dist/actions/provider-api-register.d.ts +6 -6
  2. package/dist/actions/start-workspace-app-creation.js +1 -1
  3. package/dist/actions/start-workspace-app-creation.js.map +1 -1
  4. package/dist/components/automation-run-history-dialog.d.ts.map +1 -1
  5. package/dist/components/automation-run-history-dialog.js +1 -0
  6. package/dist/components/automation-run-history-dialog.js.map +1 -1
  7. package/dist/components/create-app-popover.d.ts.map +1 -1
  8. package/dist/components/create-app-popover.js +5 -71
  9. package/dist/components/create-app-popover.js.map +1 -1
  10. package/dist/components/layout/Layout.d.ts +23 -1
  11. package/dist/components/layout/Layout.d.ts.map +1 -1
  12. package/dist/components/layout/Layout.js +702 -15
  13. package/dist/components/layout/Layout.js.map +1 -1
  14. package/dist/lib/automation-display.d.ts +6 -0
  15. package/dist/lib/automation-display.d.ts.map +1 -1
  16. package/dist/lib/automation-display.js +8 -0
  17. package/dist/lib/automation-display.js.map +1 -1
  18. package/dist/lib/automations.d.ts +1 -0
  19. package/dist/lib/automations.d.ts.map +1 -1
  20. package/dist/lib/automations.js.map +1 -1
  21. package/dist/lib/workspace-apps.d.ts +6 -0
  22. package/dist/lib/workspace-apps.d.ts.map +1 -1
  23. package/dist/lib/workspace-apps.js +21 -0
  24. package/dist/lib/workspace-apps.js.map +1 -1
  25. package/dist/routes/pages/automations.d.ts.map +1 -1
  26. package/dist/routes/pages/automations.js +16 -6
  27. package/dist/routes/pages/automations.js.map +1 -1
  28. package/dist/routes/pages/settings.d.ts.map +1 -1
  29. package/dist/routes/pages/settings.js +21 -1
  30. package/dist/routes/pages/settings.js.map +1 -1
  31. package/dist/server/index.d.ts +1 -1
  32. package/dist/server/index.d.ts.map +1 -1
  33. package/dist/server/index.js +1 -1
  34. package/dist/server/index.js.map +1 -1
  35. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  36. package/dist/server/lib/app-creation-store.js +13 -0
  37. package/dist/server/lib/app-creation-store.js.map +1 -1
  38. package/dist/server/plugins/agent-chat.js +1 -0
  39. package/dist/server/plugins/agent-chat.js.map +1 -1
  40. package/dist/server/plugins/db.d.ts +1 -0
  41. package/dist/server/plugins/db.d.ts.map +1 -1
  42. package/dist/server/plugins/db.js +1 -1
  43. package/dist/server/plugins/db.js.map +1 -1
  44. package/dist/server/plugins/integrations.js +1 -1
  45. package/dist/server/plugins/integrations.js.map +1 -1
  46. package/package.json +3 -3
  47. package/src/actions/start-workspace-app-creation.ts +1 -1
  48. package/src/components/automation-run-history-dialog.tsx +1 -0
  49. package/src/components/create-app-popover.spec.tsx +22 -0
  50. package/src/components/create-app-popover.tsx +30 -94
  51. package/src/components/layout/Layout.spec.tsx +16 -1
  52. package/src/components/layout/Layout.tsx +1356 -34
  53. package/src/lib/automation-display.spec.ts +7 -0
  54. package/src/lib/automation-display.ts +11 -0
  55. package/src/lib/automations.ts +1 -0
  56. package/src/lib/workspace-apps.ts +24 -0
  57. package/src/routes/pages/automations.tsx +45 -6
  58. package/src/routes/pages/settings.tsx +72 -0
  59. package/src/server/index.ts +4 -1
  60. package/src/server/lib/app-creation-store.spec.ts +13 -0
  61. package/src/server/lib/app-creation-store.ts +13 -0
  62. package/src/server/lib/mcp-gateway.spec.ts +18 -0
  63. package/src/server/plugins/agent-chat.ts +1 -0
  64. package/src/server/plugins/db.ts +1 -1
  65. package/src/server/plugins/integrations.ts +1 -1
  66. package/src/styles/dispatch.css +29 -0
@@ -1,13 +1,65 @@
1
1
  import {
2
+ CHAT_FIRST_MODE_CHANGED_EVENT,
3
+ AgentChatSurface,
4
+ chatFirstSurfaceTabId,
2
5
  AgentSidebar,
6
+ ChatFirstSurfacePanelToggle,
7
+ closeChatFirstSessionWatch,
8
+ emitChatFirstSessionWatch,
9
+ getChatFirstSurfaceTabsStore,
3
10
  focusAgentChat,
4
11
  navigateWithAgentChatViewTransition,
12
+ readChatFirstAppLayout,
13
+ readChatFirstMode,
14
+ resolveChatFirstBrowserTarget,
15
+ resolveChatFirstAppTarget,
16
+ subscribeChatFirstOpenBrowser,
17
+ subscribeChatFirstOpenApp,
18
+ writeChatFirstAppLayout,
19
+ useChatFirstSessionWatch,
20
+ useChatFirstSurfacePanel,
21
+ useChatFirstSurfaceResize,
22
+ useChatFirstSurfaceTabs,
23
+ type ChatFirstAppRegistration,
24
+ type ChatFirstAppLayoutPreference,
25
+ type ChatFirstAppResolution,
26
+ type ChatFirstOpenBrowserDetail,
27
+ type ChatFirstOpenAppDetail,
28
+ type ChatFirstSessionReference,
29
+ type ChatFirstSurfaceTab,
30
+ type ChatFirstSurfaceKind,
5
31
  useAgentChatHomeHandoff,
6
32
  useAgentChatHomeHandoffLinks,
7
33
  useChatThreads,
8
34
  type ChatThreadSummary,
9
35
  } from "@agent-native/core/client/agent-chat";
10
36
  import { appBasePath, appPath } from "@agent-native/core/client/api-path";
37
+ import {
38
+ readClientAppState,
39
+ writeClientAppState,
40
+ } from "@agent-native/core/client/application-state";
41
+ import {
42
+ ChatFirstAgentsPane,
43
+ ChatFirstAppPane,
44
+ ChatFirstAppsRail,
45
+ ChatFirstBrowserPane,
46
+ ChatFirstChatHistory,
47
+ ChatFirstPrimaryNavigation,
48
+ ChatFirstSessionWatchPane,
49
+ ChatFirstSurfacePanel,
50
+ ChatFirstSurfaceContent,
51
+ ChatFirstSurfaceTabs,
52
+ defaultChatFirstCopy,
53
+ type ChatFirstAgentActivity,
54
+ type ChatFirstAppItem,
55
+ type ChatFirstCopy,
56
+ type ChatFirstEmbedTarget,
57
+ } from "@agent-native/core/client/chat-first";
58
+ import { writeClipboardText } from "@agent-native/core/client/clipboard";
59
+ import {
60
+ useActionMutation,
61
+ useActionQuery,
62
+ } from "@agent-native/core/client/hooks";
11
63
  import { useT } from "@agent-native/core/client/i18n";
12
64
  import { openCommandMenu } from "@agent-native/core/client/navigation";
13
65
  import { InvitationBanner, OrgSwitcher } from "@agent-native/core/client/org";
@@ -21,6 +73,8 @@ import {
21
73
  IconApps,
22
74
  IconBrandSlack,
23
75
  IconBrandTelegram,
76
+ IconCopy,
77
+ IconEye,
24
78
  IconMessageQuestion,
25
79
  IconBroadcast,
26
80
  IconLayoutSidebarLeftCollapse,
@@ -30,6 +84,7 @@ import {
30
84
  IconSearch,
31
85
  IconWorld,
32
86
  IconDeviceDesktop,
87
+ IconPlus,
33
88
  } from "@tabler/icons-react";
34
89
  import {
35
90
  createContext,
@@ -38,12 +93,20 @@ import {
38
93
  useRef,
39
94
  useState,
40
95
  useContext,
96
+ useCallback,
41
97
  type ComponentType,
42
98
  type ReactNode,
43
99
  } from "react";
44
100
  import { Link, useLocation, useNavigate } from "react-router";
101
+ import { toast } from "sonner";
45
102
 
46
103
  import { cn } from "../../lib/utils";
104
+ import {
105
+ mergeChatFirstWorkspaceApps,
106
+ type WorkspaceAppSummary,
107
+ } from "../../lib/workspace-apps";
108
+ import { AppIcon } from "../app-icon";
109
+ import { CreateAppPopover } from "../create-app-popover";
47
110
  import {
48
111
  DropdownMenu,
49
112
  DropdownMenuContent,
@@ -80,6 +143,8 @@ export interface DispatchNavItem {
80
143
  }
81
144
 
82
145
  export interface DispatchExtensionConfig {
146
+ /** Opt into the Codex/T3-like chat-first shell for chat routes. */
147
+ chatFirst?: boolean;
83
148
  /** Extra sidebar tabs supplied by the generated workspace. */
84
149
  navItems?: readonly DispatchNavItem[];
85
150
  /** Extra React Query keys to invalidate when Dispatch receives DB sync events. */
@@ -131,6 +196,43 @@ const DISPATCH_SIDEBAR_LABEL = "Dispatch";
131
196
  const CHROMELESS_PATHS = ["/approval", "/browser-chat", "/browser-connect"];
132
197
  const SIDEBAR_COLLAPSE_KEY = "dispatch.sidebar.collapsed";
133
198
  const CHAT_HISTORY_SOURCE_KEY = "dispatch.chat-history.source";
199
+ const CHAT_FIRST_PANE_STATE_KEY = "chat-first-pane";
200
+
201
+ interface DispatchChatFirstPane {
202
+ appId: string;
203
+ path?: string;
204
+ view?: string;
205
+ }
206
+
207
+ interface ChatFirstEmbedSessionResult {
208
+ startUrl: string;
209
+ }
210
+
211
+ interface ChatFirstEmbedSessionInput {
212
+ app?: string;
213
+ path?: string;
214
+ url?: string;
215
+ chrome: "minimal";
216
+ }
217
+
218
+ export function buildChatFirstEmbedSessionInput(
219
+ appId: string,
220
+ path: string,
221
+ ): ChatFirstEmbedSessionInput {
222
+ return { app: appId, path, chrome: "minimal" };
223
+ }
224
+
225
+ interface DispatchAgentThreadSummary {
226
+ id: string;
227
+ title: string;
228
+ preview?: string;
229
+ snippet?: string;
230
+ updatedAt?: number;
231
+ }
232
+
233
+ interface SearchAgentThreadsResult {
234
+ threads: DispatchAgentThreadSummary[];
235
+ }
134
236
 
135
237
  const DispatchExtensionsContext = createContext<
136
238
  DispatchExtensionConfig | undefined
@@ -199,8 +301,160 @@ function dispatchNavLinkTarget(path: string): string {
199
301
  return routerHasBasename ? path : appPath(path);
200
302
  }
201
303
 
202
- function chatThreadPath(threadId: string): string {
203
- return `/chat/${encodeURIComponent(threadId)}`;
304
+ function chatFirstResolutionMessage(
305
+ reason: Exclude<ChatFirstAppResolution, { status: "ready" }>["reason"],
306
+ ): string {
307
+ switch (reason) {
308
+ case "empty-detail":
309
+ return "The agent did not provide an app target to open.";
310
+ case "invalid-url":
311
+ return "The requested app route was not registered for this workspace app.";
312
+ case "unknown-app":
313
+ return "That app is not available in this workspace.";
314
+ }
315
+ return "The requested app could not be opened.";
316
+ }
317
+
318
+ function chatFirstBrowserResolutionMessage(
319
+ reason: "empty-detail" | "invalid-url",
320
+ ): string {
321
+ return reason === "empty-detail"
322
+ ? "The agent did not provide a browser URL to open."
323
+ : "The requested browser URL is not a safe HTTP(S) address.";
324
+ }
325
+
326
+ const DISPATCH_CHAT_FIRST_COPY_KEYS: Record<string, string> = {
327
+ workspaceApps: "chatFirstWorkspaceApps",
328
+ createWorkspaceApp: "chatFirstCreateWorkspaceApp",
329
+ openApp: "chatFirstOpenApp",
330
+ appsLoadError: "chatFirstAppsLoadError",
331
+ noWorkspaceApps: "chatFirstNoWorkspaceApps",
332
+ createApp: "chatFirstCreateApp",
333
+ retry: "chatFirstRetry",
334
+ dismiss: "chatFirstDismiss",
335
+ unpinApp: "chatFirstUnpinApp",
336
+ pinApp: "chatFirstPinApp",
337
+ removePinned: "chatFirstRemovePinned",
338
+ pinTop: "chatFirstPinTop",
339
+ openSideSurfaces: "chatFirstOpenSideSurfaces",
340
+ closeTab: "chatFirstCloseTab",
341
+ close: "chatFirstClose",
342
+ closeOthers: "chatFirstCloseOthers",
343
+ closeToRight: "chatFirstCloseToRight",
344
+ closeAll: "chatFirstCloseAll",
345
+ unavailable: "chatFirstUnavailable",
346
+ openActivity: "chatFirstOpenActivity",
347
+ deferred: "chatFirstDeferred",
348
+ browserBack: "chatFirstBrowserBack",
349
+ browserForward: "chatFirstBrowserForward",
350
+ browserReload: "chatFirstBrowserReload",
351
+ browserAddress: "chatFirstBrowserAddress",
352
+ browserOpenExternal: "chatFirstBrowserOpenExternal",
353
+ browserClose: "chatFirstBrowserClose",
354
+ browserPage: "chatFirstBrowserPage",
355
+ browserInvalidUrl: "chatFirstBrowserInvalidUrl",
356
+ browserPreviewStarting: "chatFirstBrowserPreviewStarting",
357
+ browserPreviewError: "chatFirstBrowserPreviewError",
358
+ appUnavailable: "chatFirstAppUnavailable",
359
+ appLoading: "chatFirstLoadingApp",
360
+ agentActivityEyebrow: "chatFirstAgentActivityEyebrow",
361
+ agentActivityTitle: "chatFirstAgentActivityTitle",
362
+ agentActivityDescription: "chatFirstAgentActivityDescription",
363
+ refreshAgentActivity: "chatFirstRefreshAgentActivity",
364
+ noAgentSessions: "chatFirstNoAgentSessions",
365
+ startAgentSession: "chatFirstStartAgentSession",
366
+ watchSession: "chatFirstWatchSession",
367
+ copySessionId: "chatFirstCopySessionId",
368
+ copySessionIdFor: "chatFirstCopySessionIdFor",
369
+ sessionIdCopied: "chatFirstSessionIdCopied",
370
+ sessionIdShort: "chatFirstSessionIdShort",
371
+ copied: "chatFirstCopied",
372
+ agentActivityStatusQueued: "chatFirstAgentActivityStatusQueued",
373
+ agentActivityStatusRunning: "chatFirstAgentActivityStatusRunning",
374
+ agentActivityStatusPaused: "chatFirstAgentActivityStatusPaused",
375
+ agentActivityStatusNeedsApproval: "chatFirstAgentActivityStatusNeedsApproval",
376
+ agentActivityStatusCompleted: "chatFirstAgentActivityStatusCompleted",
377
+ agentActivityStatusErrored: "chatFirstAgentActivityStatusErrored",
378
+ agentActivityStatusRecent: "chatFirstAgentActivityStatusRecent",
379
+ agentActivityStatusUnknown: "chatFirstAgentActivityStatusUnknown",
380
+ watchingSession: "chatFirstWatchingSession",
381
+ session: "chatFirstSession",
382
+ stopWatchingSession: "chatFirstStopWatchingSession",
383
+ stopWatching: "chatFirstStopWatching",
384
+ watchedSession: "chatFirstWatchedSession",
385
+ agentsTitle: "chatFirstAgentsTitle",
386
+ };
387
+
388
+ const DISPATCH_CHAT_FIRST_SURFACE_COPY_KEYS: Record<
389
+ string,
390
+ { label: string; reason: string }
391
+ > = {
392
+ browser: {
393
+ label: "chatFirstSurfaceBrowserLabel",
394
+ reason: "chatFirstSurfaceBrowserReason",
395
+ },
396
+ terminal: {
397
+ label: "chatFirstSurfaceTerminalLabel",
398
+ reason: "chatFirstSurfaceTerminalReason",
399
+ },
400
+ files: {
401
+ label: "chatFirstSurfaceFilesLabel",
402
+ reason: "chatFirstSurfaceFilesReason",
403
+ },
404
+ diff: {
405
+ label: "chatFirstSurfaceDiffLabel",
406
+ reason: "chatFirstSurfaceDiffReason",
407
+ },
408
+ "side-chat": {
409
+ label: "chatFirstSurfaceSideChatLabel",
410
+ reason: "chatFirstSurfaceSideChatReason",
411
+ },
412
+ agents: {
413
+ label: "chatFirstSurfaceAgentsLabel",
414
+ reason: "chatFirstSurfaceAgentsReason",
415
+ },
416
+ };
417
+
418
+ function createDispatchChatFirstCopy(
419
+ t: (key: string, options?: Record<string, unknown>) => string,
420
+ ): ChatFirstCopy {
421
+ return (key, values) => {
422
+ if (key.startsWith("surface.")) {
423
+ const [, kind, field] = key.split(".");
424
+ const surface = DISPATCH_CHAT_FIRST_SURFACE_COPY_KEYS[kind];
425
+ const translationKey =
426
+ surface && (field === "label" || field === "reason")
427
+ ? surface[field]
428
+ : undefined;
429
+ if (translationKey) {
430
+ return t(`dispatch.pages.${translationKey}`, {
431
+ ...(values ?? {}),
432
+ defaultValue: defaultChatFirstCopy(key, values),
433
+ });
434
+ }
435
+ }
436
+ const translationKey = DISPATCH_CHAT_FIRST_COPY_KEYS[key];
437
+ if (!translationKey) return defaultChatFirstCopy(key, values);
438
+ return t(`dispatch.pages.${translationKey}`, {
439
+ ...(values ?? {}),
440
+ defaultValue: defaultChatFirstCopy(key, values),
441
+ });
442
+ };
443
+ }
444
+
445
+ function chatThreadPath(threadId: string | null): string {
446
+ return threadId ? `/chat/${encodeURIComponent(threadId)}` : "/chat";
447
+ }
448
+
449
+ function persistedChatFirstPane(value: unknown): DispatchChatFirstPane | null {
450
+ if (!value || typeof value !== "object") return null;
451
+ const record = value as Record<string, unknown>;
452
+ if (typeof record.appId !== "string" || !record.appId.trim()) return null;
453
+ return {
454
+ appId: record.appId,
455
+ ...(typeof record.path === "string" ? { path: record.path } : {}),
456
+ ...(typeof record.view === "string" ? { view: record.view } : {}),
457
+ };
204
458
  }
205
459
 
206
460
  function threadIdFromPath(pathname: string): string | null {
@@ -261,7 +515,19 @@ function threadUpdatedAt(thread: ChatThreadSummary) {
261
515
  : 0;
262
516
  }
263
517
 
264
- function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
518
+ function DispatchChatsSection({
519
+ onNavigate,
520
+ showNewChat = false,
521
+ prelude,
522
+ chatFirstMode = false,
523
+ chatFirstEmbedded = false,
524
+ }: {
525
+ onNavigate?: () => void;
526
+ showNewChat?: boolean;
527
+ prelude?: ReactNode;
528
+ chatFirstMode?: boolean;
529
+ chatFirstEmbedded?: boolean;
530
+ }) {
265
531
  const t = useT();
266
532
  const navigate = useNavigate();
267
533
  const location = useLocation();
@@ -390,7 +656,23 @@ function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
390
656
  }
391
657
 
392
658
  return (
393
- <div className="ms-4 min-w-0 space-y-0.5">
659
+ <div
660
+ className={cn(
661
+ "ms-4 min-w-0 space-y-0.5",
662
+ showNewChat && "dispatch-chat-first-chats",
663
+ )}
664
+ >
665
+ {showNewChat ? (
666
+ <button
667
+ type="button"
668
+ className="flex h-7 w-[calc(100%-0.5rem)] items-center gap-2 rounded-md px-2 text-sm text-sidebar-foreground/80 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
669
+ onClick={() => void handleNewChat()}
670
+ >
671
+ <IconPlus size={16} className="shrink-0" aria-hidden="true" />
672
+ <span>New chat</span>
673
+ </button>
674
+ ) : null}
675
+ {prelude}
394
676
  <div className="flex justify-end px-2 pt-0.5">
395
677
  <Tooltip>
396
678
  <TooltipTrigger asChild>
@@ -428,7 +710,8 @@ function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
428
710
  </TooltipContent>
429
711
  </Tooltip>
430
712
  </div>
431
- {chatsLoading &&
713
+ {!chatFirstMode &&
714
+ chatsLoading &&
432
715
  visibleThreads.length === 0 &&
433
716
  Array.from({ length: 3 }).map((_, index) => (
434
717
  <div
@@ -439,31 +722,147 @@ function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
439
722
  <Skeleton className="h-3 w-3/4 rounded" />
440
723
  </div>
441
724
  ))}
442
- <ChatHistoryRail
443
- items={chatItems}
444
- activeId={displayedActiveThreadId}
445
- onSelect={(threadId) => openThread(threadId)}
446
- onNewChat={() => void handleNewChat()}
447
- railLabels={{
448
- newChat: t("dispatch.sidebar.newChat"),
449
- showMore: t("dispatch.sidebar.chats"),
450
- showLess: t("dispatch.sidebar.chats"),
451
- }}
452
- renameMaxLength={160}
453
- onRename={(threadId, title) => void renameThread(threadId, title)}
454
- labels={{
455
- options: (item) =>
456
- t("dispatch.sidebar.chatOptions", {
457
- title: item.titleText ?? "",
458
- }),
459
- renameInput: (item) =>
460
- t("dispatch.sidebar.renameThread", {
461
- title: item.titleText ?? "",
462
- }),
463
- rename: t("dispatch.sidebar.renameChat"),
464
- }}
465
- className="min-w-0"
466
- />
725
+ {chatFirstMode ? (
726
+ <ChatFirstChatHistory
727
+ items={chatItems}
728
+ activeId={displayedActiveThreadId}
729
+ loading={chatsLoading && visibleThreads.length === 0}
730
+ loadingLabel={
731
+ <div className="space-y-1 px-2 py-1">
732
+ {[0, 1, 2].map((index) => (
733
+ <div key={index} className="flex items-center gap-2 px-1 py-1">
734
+ <Skeleton className="size-3.5 shrink-0 rounded-sm" />
735
+ <Skeleton className="h-3 w-3/4 rounded" />
736
+ </div>
737
+ ))}
738
+ </div>
739
+ }
740
+ label={t("dispatch.sidebar.chats", { defaultValue: "Chats" })}
741
+ onSelect={(threadId) => openThread(threadId)}
742
+ renameMaxLength={160}
743
+ onRename={(threadId, title) => void renameThread(threadId, title)}
744
+ labels={{
745
+ options: (item) =>
746
+ t("dispatch.sidebar.chatOptions", {
747
+ title: item.titleText ?? "",
748
+ }),
749
+ renameInput: (item) =>
750
+ t("dispatch.sidebar.renameThread", {
751
+ title: item.titleText ?? "",
752
+ }),
753
+ rename: t("dispatch.sidebar.renameChat"),
754
+ }}
755
+ renderAdditionalRowActions={(item, closeMenu) => (
756
+ <>
757
+ <button
758
+ type="button"
759
+ role="menuitem"
760
+ className="an-chat-history-row__menu-item"
761
+ onClick={() => {
762
+ closeMenu();
763
+ void writeClipboardText(item.id).then((copied) => {
764
+ toast[copied ? "success" : "error"](
765
+ copied
766
+ ? "Session ID copied"
767
+ : "Could not copy the session ID",
768
+ );
769
+ });
770
+ }}
771
+ >
772
+ <IconCopy size={13} strokeWidth={1.8} />
773
+ <span>Copy session ID</span>
774
+ </button>
775
+ {!chatFirstEmbedded ? (
776
+ <button
777
+ type="button"
778
+ role="menuitem"
779
+ className="an-chat-history-row__menu-item"
780
+ onClick={() => {
781
+ closeMenu();
782
+ emitChatFirstSessionWatch({
783
+ sessionId: item.id,
784
+ title: item.titleText,
785
+ kind: "agent-chat",
786
+ sourceSessionId: displayedActiveThreadId ?? undefined,
787
+ });
788
+ }}
789
+ >
790
+ <IconEye size={13} strokeWidth={1.8} />
791
+ <span>Watch and message session</span>
792
+ </button>
793
+ ) : null}
794
+ </>
795
+ )}
796
+ className="min-w-0"
797
+ />
798
+ ) : (
799
+ <ChatHistoryRail
800
+ items={chatItems}
801
+ activeId={displayedActiveThreadId}
802
+ onSelect={(threadId) => openThread(threadId)}
803
+ onNewChat={() => void handleNewChat()}
804
+ railLabels={{
805
+ newChat: t("dispatch.sidebar.newChat"),
806
+ showMore: t("dispatch.sidebar.chats"),
807
+ showLess: t("dispatch.sidebar.chats"),
808
+ }}
809
+ renameMaxLength={160}
810
+ onRename={(threadId, title) => void renameThread(threadId, title)}
811
+ labels={{
812
+ options: (item) =>
813
+ t("dispatch.sidebar.chatOptions", {
814
+ title: item.titleText ?? "",
815
+ }),
816
+ renameInput: (item) =>
817
+ t("dispatch.sidebar.renameThread", {
818
+ title: item.titleText ?? "",
819
+ }),
820
+ rename: t("dispatch.sidebar.renameChat"),
821
+ }}
822
+ renderAdditionalRowActions={(item, closeMenu) => (
823
+ <>
824
+ <button
825
+ type="button"
826
+ role="menuitem"
827
+ className="an-chat-history-row__menu-item"
828
+ onClick={() => {
829
+ closeMenu();
830
+ void writeClipboardText(item.id).then((copied) => {
831
+ toast[copied ? "success" : "error"](
832
+ copied
833
+ ? "Session ID copied"
834
+ : "Could not copy the session ID",
835
+ );
836
+ });
837
+ }}
838
+ >
839
+ <IconCopy size={13} strokeWidth={1.8} />
840
+ <span>Copy session ID</span>
841
+ </button>
842
+ {chatFirstMode && !chatFirstEmbedded ? (
843
+ <button
844
+ type="button"
845
+ role="menuitem"
846
+ className="an-chat-history-row__menu-item"
847
+ onClick={() => {
848
+ closeMenu();
849
+ emitChatFirstSessionWatch({
850
+ sessionId: item.id,
851
+ title: item.titleText,
852
+ kind: "agent-chat",
853
+ sourceSessionId: displayedActiveThreadId ?? undefined,
854
+ });
855
+ }}
856
+ >
857
+ <IconEye size={13} strokeWidth={1.8} />
858
+ <span>Watch and message session</span>
859
+ </button>
860
+ ) : null}
861
+ </>
862
+ )}
863
+ className="min-w-0"
864
+ />
865
+ )}
467
866
  </div>
468
867
  );
469
868
  }
@@ -471,17 +870,38 @@ function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
471
870
  export function NavContent({
472
871
  onNavigate,
473
872
  extensions,
873
+ chatFirstMode = false,
874
+ chatFirstEmbedded = false,
474
875
  collapsed = false,
475
876
  collapsible = false,
476
877
  onCollapsedChange,
878
+ chatFirstAppLayout,
879
+ onChatFirstAppLayoutChange,
880
+ chatFirstApps = [],
881
+ chatFirstAppsLoading = false,
882
+ chatFirstAppsError,
883
+ chatFirstActiveAppId,
884
+ onChatFirstAppOpen,
885
+ onChatFirstAppsRetry,
477
886
  }: {
478
887
  onNavigate?: () => void;
479
888
  extensions?: DispatchExtensionConfig;
889
+ chatFirstMode?: boolean;
890
+ chatFirstEmbedded?: boolean;
480
891
  collapsed?: boolean;
481
892
  collapsible?: boolean;
482
893
  onCollapsedChange?: (collapsed: boolean) => void;
894
+ chatFirstAppLayout?: ChatFirstAppLayoutPreference;
895
+ onChatFirstAppLayoutChange?: (layout: ChatFirstAppLayoutPreference) => void;
896
+ chatFirstApps?: readonly ChatFirstAppItem[];
897
+ chatFirstAppsLoading?: boolean;
898
+ chatFirstAppsError?: string | null;
899
+ chatFirstActiveAppId?: string;
900
+ onChatFirstAppOpen?: (app: ChatFirstAppItem) => void;
901
+ onChatFirstAppsRetry?: () => void;
483
902
  }) {
484
903
  const t = useT();
904
+ const chatFirstCopy = useMemo(() => createDispatchChatFirstCopy(t), [t]);
485
905
  const location = useLocation();
486
906
  const navigate = useNavigate();
487
907
  const extensionNavItems = extensions?.navItems ?? EMPTY_NAV_ITEMS;
@@ -547,6 +967,62 @@ export function NavContent({
547
967
  className={collapsed ? "size-8" : "min-w-0"}
548
968
  />
549
969
  );
970
+ const chatFirstCreateAppTrigger = (
971
+ <CreateAppPopover
972
+ align="start"
973
+ trigger={
974
+ <button
975
+ type="button"
976
+ className="flex size-6 items-center justify-center rounded text-sidebar-foreground/55 hover:bg-sidebar-accent hover:text-sidebar-foreground"
977
+ aria-label={chatFirstCopy("createWorkspaceApp")}
978
+ title={chatFirstCopy("createWorkspaceApp")}
979
+ >
980
+ <IconPlus size={14} aria-hidden="true" />
981
+ </button>
982
+ }
983
+ />
984
+ );
985
+
986
+ const chatFirstPrimaryNavigation = (
987
+ <ChatFirstPrimaryNavigation
988
+ copy={chatFirstCopy}
989
+ onOpenIntegrations={() => {
990
+ navigate(dispatchNavLinkTarget("/admin/integrations"));
991
+ onNavigate?.();
992
+ }}
993
+ onOpenScheduled={() => {
994
+ navigate(dispatchNavLinkTarget("/admin/automations"));
995
+ onNavigate?.();
996
+ }}
997
+ />
998
+ );
999
+
1000
+ const chatFirstAppsRail = (
1001
+ <ChatFirstAppsRail
1002
+ apps={chatFirstApps}
1003
+ activeAppId={chatFirstActiveAppId}
1004
+ collapsed={collapsed}
1005
+ loading={chatFirstAppsLoading}
1006
+ error={chatFirstAppsError}
1007
+ layout={chatFirstAppLayout}
1008
+ onLayoutChange={onChatFirstAppLayoutChange}
1009
+ onRetry={onChatFirstAppsRetry}
1010
+ onOpenApp={(app) => {
1011
+ onChatFirstAppOpen?.(app);
1012
+ onNavigate?.();
1013
+ }}
1014
+ createAppTrigger={chatFirstCreateAppTrigger}
1015
+ renderIcon={(app) => (
1016
+ <AppIcon
1017
+ id={app.id}
1018
+ name={app.name}
1019
+ size="sm"
1020
+ className="size-5 rounded-md"
1021
+ />
1022
+ )}
1023
+ copy={chatFirstCopy}
1024
+ />
1025
+ );
550
1026
 
551
1027
  const renderNavItem = (item: DispatchNavItem) => {
552
1028
  const Icon = item.icon;
@@ -633,7 +1109,11 @@ export function NavContent({
633
1109
  </Link>
634
1110
  )}
635
1111
  {!collapsed && item.id === "chat" && itemMatchesLocalPath ? (
636
- <DispatchChatsSection onNavigate={onNavigate} />
1112
+ <DispatchChatsSection
1113
+ onNavigate={onNavigate}
1114
+ chatFirstMode={chatFirstMode}
1115
+ chatFirstEmbedded={chatFirstEmbedded}
1116
+ />
637
1117
  ) : null}
638
1118
  </li>
639
1119
  );
@@ -688,7 +1168,33 @@ export function NavContent({
688
1168
  </div>
689
1169
  </div>
690
1170
 
691
- <div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
1171
+ {chatFirstMode ? (
1172
+ <div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
1173
+ {!collapsed ? (
1174
+ <DispatchChatsSection
1175
+ onNavigate={onNavigate}
1176
+ showNewChat
1177
+ chatFirstMode
1178
+ prelude={
1179
+ <>
1180
+ <nav className="space-y-0.5 px-2 py-2">
1181
+ {chatFirstPrimaryNavigation}
1182
+ </nav>
1183
+ {chatFirstAppsRail}
1184
+ </>
1185
+ }
1186
+ />
1187
+ ) : (
1188
+ chatFirstAppsRail
1189
+ )}
1190
+ </div>
1191
+ ) : null}
1192
+ <div
1193
+ className={cn(
1194
+ "flex min-h-0 flex-1 flex-col overflow-y-auto",
1195
+ chatFirstMode && "hidden",
1196
+ )}
1197
+ >
692
1198
  <nav className={cn("py-2", collapsed ? "px-1.5" : "px-2")}>
693
1199
  <ul
694
1200
  className={cn(
@@ -727,6 +1233,16 @@ export function NavContent({
727
1233
  collapse={collapseButton}
728
1234
  />
729
1235
  </div>
1236
+ {chatFirstMode && collapsed ? (
1237
+ <div className="mt-auto shrink-0">
1238
+ <SidebarFooterActions
1239
+ collapsed
1240
+ feedback={feedbackButton}
1241
+ search={searchButton}
1242
+ collapse={collapseButton}
1243
+ />
1244
+ </div>
1245
+ ) : null}
730
1246
  </>
731
1247
  );
732
1248
  }
@@ -755,7 +1271,275 @@ export function Layout({
755
1271
  const localPathname = localDispatchPath(location.pathname);
756
1272
  const isChatRoute =
757
1273
  localPathname === "/chat" || localPathname.startsWith("/chat/");
1274
+ const chatFirstSurfaceScope = threadIdFromPath(localPathname) ?? "new";
758
1275
  const isWorkspaceAppHostRoute = localPathname.startsWith("/apps/");
1276
+ const [chatFirstPreference, setChatFirstPreference] = useState(() =>
1277
+ readChatFirstMode(),
1278
+ );
1279
+ const chatFirstEmbedded =
1280
+ typeof window !== "undefined" &&
1281
+ new URLSearchParams(window.location.search).get("chatFirst") === "1";
1282
+ const chatFirstMode =
1283
+ extensions?.chatFirst === true || chatFirstPreference || chatFirstEmbedded;
1284
+ const [chatFirstAppLayout, setChatFirstAppLayout] =
1285
+ useState<ChatFirstAppLayoutPreference>(() => readChatFirstAppLayout());
1286
+ const chatFirstAppLayoutHydratedRef = useRef(false);
1287
+ const chatFirstAppsQuery = useActionQuery<WorkspaceAppSummary[]>(
1288
+ "list-workspace-apps",
1289
+ { includeAgentCards: false },
1290
+ { enabled: chatFirstMode && isChatRoute },
1291
+ );
1292
+ const chatFirstWorkspaceApps = useMemo(
1293
+ () => mergeChatFirstWorkspaceApps(chatFirstAppsQuery.data),
1294
+ [chatFirstAppsQuery.data],
1295
+ );
1296
+ const chatFirstAppRegistrations = useMemo<ChatFirstAppRegistration[]>(
1297
+ () =>
1298
+ chatFirstWorkspaceApps.map((app) => ({
1299
+ id: app.id,
1300
+ name: app.name,
1301
+ path: app.path,
1302
+ url: app.url,
1303
+ enabled: app.status !== "pending" && app.archived !== true,
1304
+ })),
1305
+ [chatFirstWorkspaceApps],
1306
+ );
1307
+ const chatFirstAppItems = useMemo<ChatFirstAppItem[]>(
1308
+ () =>
1309
+ chatFirstWorkspaceApps.map((app) => ({
1310
+ id: app.id,
1311
+ name: app.name,
1312
+ })),
1313
+ [chatFirstWorkspaceApps],
1314
+ );
1315
+ const chatFirstCopy = useMemo(() => createDispatchChatFirstCopy(t), [t]);
1316
+ const createChatFirstEmbedSession = useActionMutation<
1317
+ ChatFirstEmbedSessionResult,
1318
+ ChatFirstEmbedSessionInput
1319
+ >("create_embed_session", { skipActionQueryInvalidation: true });
1320
+ const [chatFirstPane, setChatFirstPane] =
1321
+ useState<DispatchChatFirstPane | null>(null);
1322
+ const [chatFirstEmbedUrl, setChatFirstEmbedUrl] = useState<string | null>(
1323
+ null,
1324
+ );
1325
+ const [chatFirstEmbedError, setChatFirstEmbedError] = useState<string | null>(
1326
+ null,
1327
+ );
1328
+ const [chatFirstEmbedAttempt, setChatFirstEmbedAttempt] = useState(0);
1329
+ const [chatFirstNotice, setChatFirstNotice] = useState<string | null>(null);
1330
+ const chatFirstSessionWatch = useChatFirstSessionWatch();
1331
+ const chatFirstSurfaceTabs = useChatFirstSurfaceTabs(chatFirstSurfaceScope);
1332
+ const chatFirstSurfaceTabsStore = getChatFirstSurfaceTabsStore(
1333
+ chatFirstSurfaceScope,
1334
+ );
1335
+ const chatFirstAgentsQuery = useActionQuery<SearchAgentThreadsResult>(
1336
+ "search-agent-threads",
1337
+ { sourceId: "current", limit: 12 },
1338
+ {
1339
+ enabled:
1340
+ chatFirstMode &&
1341
+ isChatRoute &&
1342
+ chatFirstSurfaceTabs.activeTabId?.startsWith("agents:") === true,
1343
+ },
1344
+ );
1345
+ const chatFirstSurfacePanel = useChatFirstSurfacePanel(chatFirstSurfaceScope);
1346
+ const { setOpen: setChatFirstSurfacePanelOpen } = chatFirstSurfacePanel;
1347
+ const chatFirstSurfaceResize = useChatFirstSurfaceResize(
1348
+ chatFirstSurfaceScope,
1349
+ );
1350
+ const chatFirstPaneHydratedRef = useRef(false);
1351
+ const pendingChatFirstOpenAppRef = useRef<ChatFirstOpenAppDetail | null>(
1352
+ null,
1353
+ );
1354
+ const previousChatFirstSurfaceScopeRef = useRef(chatFirstSurfaceScope);
1355
+ const previousChatFirstSurfaceTabCountRef = useRef<number | null>(null);
1356
+ const activeChatFirstSurfaceTab = useMemo(
1357
+ () =>
1358
+ chatFirstSurfaceTabs.tabs.find(
1359
+ (tab) => tab.id === chatFirstSurfaceTabs.activeTabId,
1360
+ ) ?? null,
1361
+ [chatFirstSurfaceTabs],
1362
+ );
1363
+ const activeChatFirstApp = useMemo(
1364
+ () =>
1365
+ activeChatFirstSurfaceTab?.kind === "app" &&
1366
+ activeChatFirstSurfaceTab.appId
1367
+ ? (chatFirstWorkspaceApps.find(
1368
+ (app) => app.id === activeChatFirstSurfaceTab.appId,
1369
+ ) ?? null)
1370
+ : null,
1371
+ [activeChatFirstSurfaceTab, chatFirstWorkspaceApps],
1372
+ );
1373
+ const chatFirstAgentActivities = useMemo<ChatFirstAgentActivity[]>(
1374
+ () =>
1375
+ (chatFirstAgentsQuery.data?.threads ?? []).map((thread) => ({
1376
+ sessionId: thread.id,
1377
+ title:
1378
+ thread.title || chatFirstCopy("agentsTitle", { name: thread.id }),
1379
+ subtitle: thread.snippet || thread.preview || chatFirstCopy("session"),
1380
+ status: "recent",
1381
+ ...(thread.updatedAt ? { updatedAt: thread.updatedAt } : {}),
1382
+ })),
1383
+ [chatFirstAgentsQuery.data, chatFirstCopy],
1384
+ );
1385
+ const chatFirstEmbedPath =
1386
+ activeChatFirstSurfaceTab?.kind === "app"
1387
+ ? (activeChatFirstSurfaceTab.path ??
1388
+ chatFirstPane?.path ??
1389
+ activeChatFirstApp?.path)
1390
+ : null;
1391
+ useEffect(() => {
1392
+ if (!chatFirstMode || !isChatRoute || !activeChatFirstApp) {
1393
+ setChatFirstEmbedUrl(null);
1394
+ setChatFirstEmbedError(null);
1395
+ return;
1396
+ }
1397
+ if (!chatFirstEmbedPath?.startsWith("/")) {
1398
+ setChatFirstEmbedUrl(null);
1399
+ setChatFirstEmbedError(chatFirstCopy("appUnavailable"));
1400
+ return;
1401
+ }
1402
+ let cancelled = false;
1403
+ setChatFirstEmbedUrl(null);
1404
+ setChatFirstEmbedError(null);
1405
+ void createChatFirstEmbedSession
1406
+ .mutateAsync(
1407
+ buildChatFirstEmbedSessionInput(
1408
+ activeChatFirstApp.id,
1409
+ chatFirstEmbedPath,
1410
+ ),
1411
+ )
1412
+ .then((result) => {
1413
+ if (!cancelled) setChatFirstEmbedUrl(result.startUrl);
1414
+ })
1415
+ .catch((cause: unknown) => {
1416
+ if (!cancelled) {
1417
+ setChatFirstEmbedError(
1418
+ cause instanceof Error
1419
+ ? cause.message
1420
+ : chatFirstCopy("appUnavailable"),
1421
+ );
1422
+ }
1423
+ });
1424
+ return () => {
1425
+ cancelled = true;
1426
+ };
1427
+ }, [
1428
+ activeChatFirstApp,
1429
+ chatFirstCopy,
1430
+ chatFirstEmbedAttempt,
1431
+ chatFirstEmbedPath,
1432
+ chatFirstMode,
1433
+ createChatFirstEmbedSession.mutateAsync,
1434
+ isChatRoute,
1435
+ ]);
1436
+ const persistChatFirstPane = useCallback(
1437
+ (pane: DispatchChatFirstPane | null) => {
1438
+ setChatFirstPane(pane);
1439
+ void writeClientAppState(CHAT_FIRST_PANE_STATE_KEY, pane).catch(() => {
1440
+ setChatFirstNotice(
1441
+ "The app pane opened locally, but its focus could not be synced.",
1442
+ );
1443
+ });
1444
+ },
1445
+ [],
1446
+ );
1447
+ const openChatFirstPane = useCallback(
1448
+ (pane: DispatchChatFirstPane) => {
1449
+ setChatFirstNotice(null);
1450
+ closeChatFirstSessionWatch();
1451
+ const app = chatFirstAppRegistrations.find(
1452
+ (candidate) => candidate.id === pane.appId,
1453
+ );
1454
+ chatFirstSurfaceTabsStore.open({
1455
+ id: chatFirstSurfaceTabId(
1456
+ "app",
1457
+ `${pane.appId}:${pane.path ?? pane.view ?? "/"}`,
1458
+ ),
1459
+ kind: "app",
1460
+ title: app?.name ?? pane.appId,
1461
+ appId: pane.appId,
1462
+ ...(pane.path ? { path: pane.path } : {}),
1463
+ ...(pane.view ? { view: pane.view } : {}),
1464
+ });
1465
+ persistChatFirstPane(pane);
1466
+ },
1467
+ [
1468
+ chatFirstAppRegistrations,
1469
+ chatFirstSurfaceTabsStore,
1470
+ persistChatFirstPane,
1471
+ ],
1472
+ );
1473
+ const openChatFirstSurface = useCallback(
1474
+ (kind: ChatFirstSurfaceKind) => {
1475
+ if (kind !== "agents") return;
1476
+ persistChatFirstPane(null);
1477
+ closeChatFirstSessionWatch();
1478
+ chatFirstSurfaceTabsStore.open({
1479
+ id: chatFirstSurfaceTabId("agents", "activity"),
1480
+ kind: "agents",
1481
+ title: "Agents",
1482
+ });
1483
+ },
1484
+ [chatFirstSurfaceTabsStore, persistChatFirstPane],
1485
+ );
1486
+ const resolveChatFirstOpenBrowser = useCallback(
1487
+ (detail: ChatFirstOpenBrowserDetail) => {
1488
+ const resolution = resolveChatFirstBrowserTarget(detail);
1489
+ if (resolution.status === "unresolved") {
1490
+ setChatFirstNotice(
1491
+ chatFirstBrowserResolutionMessage(resolution.reason),
1492
+ );
1493
+ return;
1494
+ }
1495
+ persistChatFirstPane(null);
1496
+ closeChatFirstSessionWatch();
1497
+ setChatFirstNotice(null);
1498
+ chatFirstSurfaceTabsStore.open({
1499
+ id: chatFirstSurfaceTabId("browser", resolution.target.url),
1500
+ kind: "browser",
1501
+ title: resolution.target.title ?? "Browser",
1502
+ url: resolution.target.url,
1503
+ });
1504
+ },
1505
+ [chatFirstSurfaceTabsStore, persistChatFirstPane],
1506
+ );
1507
+ const resolveChatFirstOpenApp = useCallback(
1508
+ (detail: ChatFirstOpenAppDetail) => {
1509
+ if (chatFirstAppsQuery.isLoading) {
1510
+ pendingChatFirstOpenAppRef.current = detail;
1511
+ return;
1512
+ }
1513
+ if (chatFirstAppsQuery.isError) {
1514
+ pendingChatFirstOpenAppRef.current = null;
1515
+ setChatFirstNotice(
1516
+ "Workspace apps could not be loaded, so the requested app was not opened.",
1517
+ );
1518
+ return;
1519
+ }
1520
+
1521
+ const resolution = resolveChatFirstAppTarget(
1522
+ detail,
1523
+ chatFirstAppRegistrations,
1524
+ {
1525
+ currentOrigin:
1526
+ typeof window === "undefined" ? undefined : window.location.origin,
1527
+ },
1528
+ );
1529
+ pendingChatFirstOpenAppRef.current = null;
1530
+ if (resolution.status === "unresolved") {
1531
+ setChatFirstNotice(chatFirstResolutionMessage(resolution.reason));
1532
+ return;
1533
+ }
1534
+ openChatFirstPane(resolution.target);
1535
+ },
1536
+ [
1537
+ chatFirstAppRegistrations,
1538
+ chatFirstAppsQuery.isError,
1539
+ chatFirstAppsQuery.isLoading,
1540
+ openChatFirstPane,
1541
+ ],
1542
+ );
759
1543
  const sidebarBeforeAppRef = useRef<boolean | null>(null);
760
1544
  const sidebarAutoCollapsedRef = useRef(false);
761
1545
  const chatHomeHandoffActive = useAgentChatHomeHandoff({
@@ -771,6 +1555,227 @@ export function Layout({
771
1555
  };
772
1556
  useAgentChatHomeHandoffLinks(chatHandoffLinkOptions);
773
1557
 
1558
+ useEffect(() => {
1559
+ const handleModeChange = (event: Event) => {
1560
+ const enabled = (event as CustomEvent<{ enabled?: unknown }>).detail
1561
+ ?.enabled;
1562
+ setChatFirstPreference(enabled === true);
1563
+ };
1564
+ window.addEventListener(CHAT_FIRST_MODE_CHANGED_EVENT, handleModeChange);
1565
+ return () =>
1566
+ window.removeEventListener(
1567
+ CHAT_FIRST_MODE_CHANGED_EVENT,
1568
+ handleModeChange,
1569
+ );
1570
+ }, []);
1571
+
1572
+ useEffect(() => {
1573
+ if (
1574
+ !chatFirstMode ||
1575
+ !isChatRoute ||
1576
+ chatFirstAppLayoutHydratedRef.current
1577
+ ) {
1578
+ return;
1579
+ }
1580
+ chatFirstAppLayoutHydratedRef.current = true;
1581
+ void readClientAppState<unknown>("chat-first-app-layout")
1582
+ .then((value) => {
1583
+ if (!value || typeof value !== "object") return;
1584
+ const candidate = value as Partial<ChatFirstAppLayoutPreference>;
1585
+ const ids = (input: unknown) =>
1586
+ Array.isArray(input)
1587
+ ? input.filter(
1588
+ (id): id is string =>
1589
+ typeof id === "string" && id.trim().length > 0,
1590
+ )
1591
+ : [];
1592
+ setChatFirstAppLayout({
1593
+ pinnedIds: [...new Set(ids(candidate.pinnedIds))],
1594
+ orderedIds: [...new Set(ids(candidate.orderedIds))],
1595
+ });
1596
+ })
1597
+ .catch(() => {
1598
+ // Device-local layout remains the fallback when workspace state is unavailable.
1599
+ });
1600
+ }, [chatFirstMode, isChatRoute]);
1601
+
1602
+ const persistChatFirstAppLayout = useCallback(
1603
+ (layout: ChatFirstAppLayoutPreference) => {
1604
+ setChatFirstAppLayout(layout);
1605
+ void writeClientAppState("chat-first-app-layout", layout).catch(() => {
1606
+ setChatFirstNotice(
1607
+ "App order changed locally, but workspace state could not be synced.",
1608
+ );
1609
+ });
1610
+ },
1611
+ [],
1612
+ );
1613
+
1614
+ useEffect(() => {
1615
+ if (!chatFirstMode || !isChatRoute) {
1616
+ setChatFirstPane(null);
1617
+ closeChatFirstSessionWatch();
1618
+ chatFirstSurfaceTabsStore.closeAll();
1619
+ setChatFirstNotice(null);
1620
+ pendingChatFirstOpenAppRef.current = null;
1621
+ chatFirstPaneHydratedRef.current = false;
1622
+ return;
1623
+ }
1624
+ const unsubscribeApp = subscribeChatFirstOpenApp(resolveChatFirstOpenApp);
1625
+ const unsubscribeBrowser = subscribeChatFirstOpenBrowser(
1626
+ resolveChatFirstOpenBrowser,
1627
+ );
1628
+ return () => {
1629
+ unsubscribeApp();
1630
+ unsubscribeBrowser();
1631
+ };
1632
+ }, [
1633
+ chatFirstMode,
1634
+ isChatRoute,
1635
+ chatFirstSurfaceTabsStore,
1636
+ resolveChatFirstOpenApp,
1637
+ resolveChatFirstOpenBrowser,
1638
+ ]);
1639
+
1640
+ useEffect(() => {
1641
+ if (
1642
+ !chatFirstMode ||
1643
+ !isChatRoute ||
1644
+ chatFirstPaneHydratedRef.current ||
1645
+ chatFirstAppsQuery.isLoading ||
1646
+ chatFirstAppsQuery.isError
1647
+ ) {
1648
+ return;
1649
+ }
1650
+ chatFirstPaneHydratedRef.current = true;
1651
+ if (chatFirstPane) return;
1652
+ let active = true;
1653
+ void readClientAppState<unknown>(CHAT_FIRST_PANE_STATE_KEY)
1654
+ .then((value) => {
1655
+ if (!active) return;
1656
+ const persisted = persistedChatFirstPane(value);
1657
+ if (!persisted) return;
1658
+ const resolution = resolveChatFirstAppTarget(
1659
+ {
1660
+ app: persisted.appId,
1661
+ path: persisted.path,
1662
+ view: persisted.view,
1663
+ },
1664
+ chatFirstAppRegistrations,
1665
+ {
1666
+ currentOrigin:
1667
+ typeof window === "undefined"
1668
+ ? undefined
1669
+ : window.location.origin,
1670
+ },
1671
+ );
1672
+ if (resolution.status === "ready") openChatFirstPane(resolution.target);
1673
+ })
1674
+ .catch(() => {
1675
+ if (active) {
1676
+ setChatFirstNotice(
1677
+ "The last app pane could not be restored from workspace state.",
1678
+ );
1679
+ }
1680
+ });
1681
+ return () => {
1682
+ active = false;
1683
+ };
1684
+ }, [
1685
+ chatFirstAppsQuery.isError,
1686
+ chatFirstAppsQuery.isLoading,
1687
+ chatFirstAppRegistrations,
1688
+ chatFirstMode,
1689
+ chatFirstPane,
1690
+ isChatRoute,
1691
+ openChatFirstPane,
1692
+ ]);
1693
+
1694
+ useEffect(() => {
1695
+ if (previousChatFirstSurfaceScopeRef.current === chatFirstSurfaceScope) {
1696
+ return;
1697
+ }
1698
+ previousChatFirstSurfaceScopeRef.current = chatFirstSurfaceScope;
1699
+ persistChatFirstPane(null);
1700
+ setChatFirstEmbedUrl(null);
1701
+ setChatFirstEmbedError(null);
1702
+ setChatFirstNotice(null);
1703
+ pendingChatFirstOpenAppRef.current = null;
1704
+ chatFirstPaneHydratedRef.current = true;
1705
+ chatFirstSurfaceTabsStore.closeAll();
1706
+ setChatFirstSurfacePanelOpen(false);
1707
+ closeChatFirstSessionWatch();
1708
+ }, [
1709
+ chatFirstSurfaceScope,
1710
+ chatFirstSurfaceTabsStore,
1711
+ persistChatFirstPane,
1712
+ setChatFirstSurfacePanelOpen,
1713
+ ]);
1714
+
1715
+ useEffect(() => {
1716
+ const tabCount = chatFirstSurfaceTabs.tabs.length;
1717
+ const previousTabCount = previousChatFirstSurfaceTabCountRef.current;
1718
+ if (!chatFirstMode || !isChatRoute) {
1719
+ setChatFirstSurfacePanelOpen(false);
1720
+ } else if (
1721
+ tabCount > 0 &&
1722
+ (previousTabCount === null || previousTabCount === 0)
1723
+ ) {
1724
+ setChatFirstSurfacePanelOpen(true);
1725
+ } else if (
1726
+ tabCount === 0 &&
1727
+ previousTabCount !== null &&
1728
+ previousTabCount > 0
1729
+ ) {
1730
+ setChatFirstSurfacePanelOpen(false);
1731
+ }
1732
+ previousChatFirstSurfaceTabCountRef.current = tabCount;
1733
+ }, [
1734
+ chatFirstMode,
1735
+ setChatFirstSurfacePanelOpen,
1736
+ chatFirstSurfaceTabs.tabs.length,
1737
+ isChatRoute,
1738
+ ]);
1739
+
1740
+ useEffect(() => {
1741
+ const activeTab = activeChatFirstSurfaceTab;
1742
+ if (
1743
+ activeTab?.kind === "side-chat" &&
1744
+ activeTab.session &&
1745
+ !chatFirstSessionWatch.target
1746
+ ) {
1747
+ emitChatFirstSessionWatch(activeTab.session);
1748
+ }
1749
+ }, [activeChatFirstSurfaceTab, chatFirstSessionWatch.target]);
1750
+
1751
+ useEffect(() => {
1752
+ const pending = pendingChatFirstOpenAppRef.current;
1753
+ if (!pending || chatFirstAppsQuery.isLoading) return;
1754
+ resolveChatFirstOpenApp(pending);
1755
+ }, [
1756
+ chatFirstAppsQuery.data,
1757
+ chatFirstAppsQuery.isError,
1758
+ chatFirstAppsQuery.isLoading,
1759
+ resolveChatFirstOpenApp,
1760
+ ]);
1761
+
1762
+ useEffect(() => {
1763
+ const target = chatFirstSessionWatch.target;
1764
+ if (!target || !chatFirstMode || !isChatRoute) return;
1765
+ setChatFirstPane(null);
1766
+ chatFirstSurfaceTabsStore.open({
1767
+ id: chatFirstSurfaceTabId("side-chat", target.sessionId),
1768
+ kind: "side-chat",
1769
+ title: target.title ? `Watch · ${target.title}` : "Watched session",
1770
+ session: target,
1771
+ });
1772
+ }, [
1773
+ chatFirstMode,
1774
+ chatFirstSessionWatch.target,
1775
+ chatFirstSurfaceTabsStore,
1776
+ isChatRoute,
1777
+ ]);
1778
+
774
1779
  useEffect(() => {
775
1780
  if (isWorkspaceAppHostRoute) {
776
1781
  if (!sidebarAutoCollapsedRef.current) {
@@ -805,10 +1810,222 @@ export function Layout({
805
1810
  }
806
1811
  }, [isWorkspaceAppHostRoute, sidebarCollapsed]);
807
1812
 
1813
+ const activateChatFirstSurfaceTab = useCallback(
1814
+ (tab: (typeof chatFirstSurfaceTabs.tabs)[number]) => {
1815
+ chatFirstSurfaceTabsStore.activate(tab.id);
1816
+ if (tab.kind === "app" && tab.appId) {
1817
+ closeChatFirstSessionWatch();
1818
+ persistChatFirstPane({
1819
+ appId: tab.appId,
1820
+ ...(tab.path ? { path: tab.path } : {}),
1821
+ ...(tab.view ? { view: tab.view } : {}),
1822
+ });
1823
+ return;
1824
+ }
1825
+ if (tab.kind === "browser" && tab.url) {
1826
+ persistChatFirstPane(null);
1827
+ closeChatFirstSessionWatch();
1828
+ return;
1829
+ }
1830
+ if (tab.kind === "side-chat" && tab.session) {
1831
+ persistChatFirstPane(null);
1832
+ emitChatFirstSessionWatch(tab.session);
1833
+ return;
1834
+ }
1835
+ if (tab.kind === "agents") {
1836
+ persistChatFirstPane(null);
1837
+ closeChatFirstSessionWatch();
1838
+ }
1839
+ },
1840
+ [
1841
+ chatFirstSurfaceTabs.tabs,
1842
+ chatFirstSurfaceTabsStore,
1843
+ persistChatFirstPane,
1844
+ ],
1845
+ );
1846
+
1847
+ const closeChatFirstSurfaceTab = useCallback(
1848
+ (tab: (typeof chatFirstSurfaceTabs.tabs)[number]) => {
1849
+ const isActive = chatFirstSurfaceTabs.activeTabId === tab.id;
1850
+ if (tab.kind === "app" && isActive) persistChatFirstPane(null);
1851
+ if (tab.kind === "side-chat" && isActive) {
1852
+ closeChatFirstSessionWatch();
1853
+ }
1854
+ chatFirstSurfaceTabsStore.close(tab.id);
1855
+ },
1856
+ [chatFirstSurfaceTabs, chatFirstSurfaceTabsStore, persistChatFirstPane],
1857
+ );
1858
+
1859
+ const closeAllChatFirstSurfaceTabs = useCallback(() => {
1860
+ persistChatFirstPane(null);
1861
+ closeChatFirstSessionWatch();
1862
+ chatFirstSurfaceTabsStore.closeAll();
1863
+ }, [chatFirstSurfaceTabsStore, persistChatFirstPane]);
1864
+
1865
+ const renderChatFirstWatchChat = useCallback(
1866
+ (target: ChatFirstSessionReference) => (
1867
+ <AgentChatSurface
1868
+ mode="page"
1869
+ className="h-full min-h-0 w-full"
1870
+ defaultMode="chat"
1871
+ storageKey={`dispatch-session-watch:${target.sessionId}`}
1872
+ restoreActiveThread={false}
1873
+ threadUrlSync={{
1874
+ routeThreadId: target.sessionId,
1875
+ getPath: chatThreadPath,
1876
+ navigate: () => undefined,
1877
+ }}
1878
+ showHeader={false}
1879
+ showTabBar={false}
1880
+ showPageNewChatButton={false}
1881
+ chatOnly
1882
+ dynamicSuggestions={false}
1883
+ suggestions={[]}
1884
+ emptyStateDisplay="hidden"
1885
+ composerPlaceholder={t(
1886
+ "dispatch.pages.chatFirstSessionMessagePlaceholder",
1887
+ )}
1888
+ />
1889
+ ),
1890
+ [t],
1891
+ );
1892
+
1893
+ const renderChatFirstSurfaceTab = useCallback(
1894
+ (tab: ChatFirstSurfaceTab) => {
1895
+ if (tab.kind === "app") {
1896
+ if (tab.id !== chatFirstSurfaceTabs.activeTabId) return null;
1897
+ const app = tab.appId
1898
+ ? (chatFirstWorkspaceApps.find(
1899
+ (candidate) => candidate.id === tab.appId,
1900
+ ) ?? null)
1901
+ : null;
1902
+ return (
1903
+ <ChatFirstAppPane
1904
+ app={app}
1905
+ status={
1906
+ chatFirstEmbedError
1907
+ ? "error"
1908
+ : chatFirstEmbedUrl
1909
+ ? "ready"
1910
+ : chatFirstAppsQuery.isLoading
1911
+ ? "loading"
1912
+ : app
1913
+ ? "loading"
1914
+ : "unresolved"
1915
+ }
1916
+ embedUrl={chatFirstEmbedUrl}
1917
+ errorMessage={chatFirstEmbedError}
1918
+ onRetry={() => setChatFirstEmbedAttempt((value) => value + 1)}
1919
+ renderEmbed={({ url, title }: ChatFirstEmbedTarget) => (
1920
+ <iframe
1921
+ data-dispatch-chat-first-app-frame
1922
+ src={url}
1923
+ title={title ?? chatFirstCopy("appUnavailable")}
1924
+ referrerPolicy="no-referrer"
1925
+ allow="clipboard-read; clipboard-write"
1926
+ className="h-full w-full border-0 bg-background"
1927
+ />
1928
+ )}
1929
+ copy={chatFirstCopy}
1930
+ />
1931
+ );
1932
+ }
1933
+ if (tab.kind === "browser" && tab.url) {
1934
+ return (
1935
+ <ChatFirstBrowserPane
1936
+ url={tab.url}
1937
+ title={tab.title}
1938
+ onClose={() => closeChatFirstSurfaceTab(tab)}
1939
+ renderEmbed={({ url, title, key }: ChatFirstEmbedTarget) => (
1940
+ <iframe
1941
+ key={key}
1942
+ src={url}
1943
+ title={title ?? chatFirstCopy("browserPage")}
1944
+ referrerPolicy="no-referrer"
1945
+ allow="clipboard-read; clipboard-write"
1946
+ className="h-full w-full border-0 bg-background"
1947
+ />
1948
+ )}
1949
+ copy={chatFirstCopy}
1950
+ />
1951
+ );
1952
+ }
1953
+ if (tab.kind === "side-chat") {
1954
+ const target =
1955
+ tab.session ??
1956
+ (tab.id === chatFirstSurfaceTabs.activeTabId
1957
+ ? chatFirstSessionWatch.target
1958
+ : null);
1959
+ return target ? (
1960
+ <ChatFirstSessionWatchPane
1961
+ target={target}
1962
+ onClose={() => closeChatFirstSurfaceTab(tab)}
1963
+ renderChat={renderChatFirstWatchChat}
1964
+ copy={chatFirstCopy}
1965
+ />
1966
+ ) : null;
1967
+ }
1968
+ if (tab.kind === "agents") {
1969
+ return (
1970
+ <ChatFirstAgentsPane
1971
+ activities={chatFirstAgentActivities}
1972
+ loading={chatFirstAgentsQuery.isLoading}
1973
+ error={
1974
+ chatFirstAgentsQuery.isError
1975
+ ? chatFirstAgentsQuery.error.message
1976
+ : null
1977
+ }
1978
+ onRefresh={() => void chatFirstAgentsQuery.refetch()}
1979
+ onWatch={(activity) =>
1980
+ emitChatFirstSessionWatch({
1981
+ sessionId: activity.sessionId,
1982
+ title: activity.title,
1983
+ kind: "agent-chat",
1984
+ ...(activity.goalId ? { goalId: activity.goalId } : {}),
1985
+ })
1986
+ }
1987
+ copy={chatFirstCopy}
1988
+ />
1989
+ );
1990
+ }
1991
+ return null;
1992
+ },
1993
+ [
1994
+ chatFirstAgentActivities,
1995
+ chatFirstAgentsQuery.error,
1996
+ chatFirstAgentsQuery.isError,
1997
+ chatFirstAgentsQuery.isLoading,
1998
+ chatFirstAppsQuery.isLoading,
1999
+ chatFirstCopy,
2000
+ chatFirstEmbedError,
2001
+ chatFirstEmbedUrl,
2002
+ chatFirstSessionWatch.target,
2003
+ chatFirstSurfaceTabs.activeTabId,
2004
+ chatFirstWorkspaceApps,
2005
+ closeChatFirstSurfaceTab,
2006
+ renderChatFirstWatchChat,
2007
+ ],
2008
+ );
2009
+
808
2010
  if (CHROMELESS_PATHS.some((path) => localPathname === path)) {
809
2011
  return <>{children}</>;
810
2012
  }
811
2013
 
2014
+ if (isWorkspaceAppHostRoute) {
2015
+ return (
2016
+ <DispatchExtensionsContext.Provider value={extensions}>
2017
+ <HeaderActionsProvider>
2018
+ <div
2019
+ data-dispatch-workspace-app-chrome-less
2020
+ className="h-screen w-full overflow-hidden bg-background"
2021
+ >
2022
+ {children}
2023
+ </div>
2024
+ </HeaderActionsProvider>
2025
+ </DispatchExtensionsContext.Provider>
2026
+ );
2027
+ }
2028
+
812
2029
  const showHeader = !isChatRoute && !pageOwnsToolbar(localPathname);
813
2030
  function openAskAgentFullscreen() {
814
2031
  focusAgentChat();
@@ -823,9 +2040,32 @@ export function Layout({
823
2040
  t("dispatch.sidebar.suggestionGrantKey"),
824
2041
  ];
825
2042
  const appContent = (
826
- <div className="flex h-full min-w-0 flex-1 flex-col overflow-hidden">
2043
+ <div className="relative flex h-full min-w-0 flex-1 flex-col overflow-hidden">
827
2044
  {showHeader ? <Header onOpenMobile={() => setMobileOpen(true)} /> : null}
828
2045
  <InvitationBanner />
2046
+ {isChatRoute && chatFirstMode ? (
2047
+ <ChatFirstSurfacePanelToggle
2048
+ open={chatFirstSurfacePanel.open}
2049
+ onToggle={chatFirstSurfacePanel.toggle}
2050
+ className="absolute right-3 top-2 z-[4]"
2051
+ />
2052
+ ) : null}
2053
+ {isChatRoute && chatFirstMode && chatFirstNotice ? (
2054
+ <div
2055
+ className="dispatch-chat-first-notice"
2056
+ role="status"
2057
+ aria-live="polite"
2058
+ >
2059
+ <span>{chatFirstNotice}</span>
2060
+ <button
2061
+ type="button"
2062
+ onClick={() => setChatFirstNotice(null)}
2063
+ aria-label="Dismiss app notice"
2064
+ >
2065
+ Dismiss
2066
+ </button>
2067
+ </div>
2068
+ ) : null}
829
2069
  <main
830
2070
  className={cn(
831
2071
  "flex-1",
@@ -845,8 +2085,52 @@ export function Layout({
845
2085
  </div>
846
2086
  );
847
2087
  const content = isChatRoute ? (
848
- <div className="agent-layout-main-surface flex min-w-0 flex-1 overflow-hidden">
2088
+ <div
2089
+ className={cn(
2090
+ "agent-layout-main-surface flex min-w-0 flex-1 overflow-hidden",
2091
+ chatFirstMode && "dispatch-chat-first-surface",
2092
+ )}
2093
+ >
849
2094
  {appContent}
2095
+ {chatFirstMode && chatFirstSurfacePanel.open ? (
2096
+ <ChatFirstSurfacePanel
2097
+ width={chatFirstSurfaceResize.width}
2098
+ onResizePointerDown={chatFirstSurfaceResize.onPointerDown}
2099
+ copy={chatFirstCopy}
2100
+ >
2101
+ <ChatFirstSurfaceTabs
2102
+ tabs={chatFirstSurfaceTabs.tabs}
2103
+ activeTabId={chatFirstSurfaceTabs.activeTabId}
2104
+ onActivate={activateChatFirstSurfaceTab}
2105
+ onClose={closeChatFirstSurfaceTab}
2106
+ onCloseOthers={(tab) => {
2107
+ activateChatFirstSurfaceTab(tab);
2108
+ chatFirstSurfaceTabsStore.closeOthers(tab.id);
2109
+ }}
2110
+ onCloseToRight={(tab) => {
2111
+ const targetIndex = chatFirstSurfaceTabs.tabs.findIndex(
2112
+ (candidate) => candidate.id === tab.id,
2113
+ );
2114
+ const activeIndex = chatFirstSurfaceTabs.tabs.findIndex(
2115
+ (candidate) =>
2116
+ candidate.id === chatFirstSurfaceTabs.activeTabId,
2117
+ );
2118
+ if (activeIndex > targetIndex) activateChatFirstSurfaceTab(tab);
2119
+ chatFirstSurfaceTabsStore.closeToRight(tab.id);
2120
+ }}
2121
+ onCloseAll={closeAllChatFirstSurfaceTabs}
2122
+ onOpenSurface={openChatFirstSurface}
2123
+ copy={chatFirstCopy}
2124
+ />
2125
+ {chatFirstSurfaceTabs.tabs.length > 0 ? (
2126
+ <ChatFirstSurfaceContent
2127
+ tabs={chatFirstSurfaceTabs.tabs}
2128
+ activeTabId={chatFirstSurfaceTabs.activeTabId}
2129
+ renderTab={renderChatFirstSurfaceTab}
2130
+ />
2131
+ ) : null}
2132
+ </ChatFirstSurfacePanel>
2133
+ ) : null}
850
2134
  </div>
851
2135
  ) : (
852
2136
  <AgentSidebar
@@ -877,7 +2161,25 @@ export function Layout({
877
2161
  >
878
2162
  <NavContent
879
2163
  extensions={extensions}
2164
+ chatFirstMode={chatFirstMode}
2165
+ chatFirstEmbedded={chatFirstEmbedded}
880
2166
  collapsed={sidebarCollapsed}
2167
+ chatFirstAppLayout={chatFirstAppLayout}
2168
+ onChatFirstAppLayoutChange={persistChatFirstAppLayout}
2169
+ chatFirstApps={chatFirstAppItems}
2170
+ chatFirstAppsLoading={chatFirstAppsQuery.isLoading}
2171
+ chatFirstAppsError={
2172
+ chatFirstAppsQuery.isError
2173
+ ? chatFirstCopy("appsLoadError")
2174
+ : null
2175
+ }
2176
+ chatFirstActiveAppId={
2177
+ activeChatFirstSurfaceTab?.kind === "app"
2178
+ ? activeChatFirstSurfaceTab.appId
2179
+ : undefined
2180
+ }
2181
+ onChatFirstAppOpen={(app) => openChatFirstPane({ appId: app.id })}
2182
+ onChatFirstAppsRetry={() => void chatFirstAppsQuery.refetch()}
881
2183
  collapsible
882
2184
  onCollapsedChange={setSidebarCollapsed}
883
2185
  />
@@ -897,7 +2199,27 @@ export function Layout({
897
2199
  <div className="flex h-full w-full flex-col">
898
2200
  <NavContent
899
2201
  extensions={extensions}
2202
+ chatFirstMode={chatFirstMode}
2203
+ chatFirstEmbedded={chatFirstEmbedded}
900
2204
  collapsed={false}
2205
+ chatFirstAppLayout={chatFirstAppLayout}
2206
+ onChatFirstAppLayoutChange={persistChatFirstAppLayout}
2207
+ chatFirstApps={chatFirstAppItems}
2208
+ chatFirstAppsLoading={chatFirstAppsQuery.isLoading}
2209
+ chatFirstAppsError={
2210
+ chatFirstAppsQuery.isError
2211
+ ? chatFirstCopy("appsLoadError")
2212
+ : null
2213
+ }
2214
+ chatFirstActiveAppId={
2215
+ activeChatFirstSurfaceTab?.kind === "app"
2216
+ ? activeChatFirstSurfaceTab.appId
2217
+ : undefined
2218
+ }
2219
+ onChatFirstAppOpen={(app) =>
2220
+ openChatFirstPane({ appId: app.id })
2221
+ }
2222
+ onChatFirstAppsRetry={() => void chatFirstAppsQuery.refetch()}
901
2223
  onNavigate={() => setMobileOpen(false)}
902
2224
  />
903
2225
  </div>