@agent-native/dispatch 0.40.36 → 0.40.38

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.
@@ -4,6 +4,7 @@ import { isSettingsPathname } from "@agent-native/toolkit/app/settings";
4
4
  import React, { act } from "react";
5
5
  import { createRoot, type Root } from "react-dom/client";
6
6
  import { MemoryRouter, useLocation, useNavigate } from "react-router";
7
+ import { toast } from "sonner";
7
8
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
9
 
9
10
  import { CHAT_FIRST_PANE_STATE_KEY } from "../../shared/chat-first-pane";
@@ -13,7 +14,6 @@ import {
13
14
  buildChatFirstEmbedSessionInput,
14
15
  CHAT_FIRST_SURFACE_PANEL_TOGGLE_CLASS_NAME,
15
16
  dispatchNavLinkTarget,
16
- formatThreadAge,
17
17
  isElectronEmbeddedSearch,
18
18
  isRedesignedSettingsPath,
19
19
  Layout,
@@ -26,6 +26,16 @@ import {
26
26
  const clientState = vi.hoisted(() => ({
27
27
  createThread: vi.fn<() => Promise<string | null>>(),
28
28
  switchThread: vi.fn(),
29
+ renameThread: vi.fn().mockResolvedValue(true),
30
+ pinThread: vi.fn().mockResolvedValue(true),
31
+ archiveThread: vi.fn().mockResolvedValue(true),
32
+ isNewThread: () => false,
33
+ mineThreads: null as Array<Record<string, unknown>> | null,
34
+ sharedThreads: [] as Array<Record<string, unknown>>,
35
+ sharedHasMore: false,
36
+ loadMore: vi.fn().mockResolvedValue(undefined),
37
+ listQueries: vi.fn(),
38
+ lifecycleOptions: vi.fn(),
29
39
  threads: [] as Array<Record<string, unknown>>,
30
40
  workspaceApps: [] as Array<Record<string, unknown>>,
31
41
  workspaceAppsError: null as Error | null,
@@ -70,15 +80,40 @@ vi.mock("@agent-native/core/client/agent-chat", async (importOriginal) => ({
70
80
  onEffortChange: vi.fn(),
71
81
  refreshEngines: vi.fn(),
72
82
  }),
73
- useChatThreads: () => ({
74
- threads: clientState.threads,
75
- activeThreadId: "active-thread",
76
- isLoading: false,
77
- createThread: clientState.createThread,
78
- switchThread: clientState.switchThread,
79
- renameThread: vi.fn(),
80
- refreshThreads: vi.fn(),
81
- }),
83
+ useChatThreadList: (options: {
84
+ ownership: string;
85
+ includeExternal?: boolean;
86
+ }) => {
87
+ clientState.listQueries(options);
88
+ return {
89
+ threads:
90
+ options.ownership === "shared"
91
+ ? clientState.sharedThreads
92
+ : (clientState.mineThreads ?? clientState.threads),
93
+ loading: false,
94
+ loadingMore: false,
95
+ error: null,
96
+ hasMore: options.ownership === "shared" && clientState.sharedHasMore,
97
+ loadMore: clientState.loadMore,
98
+ refresh: async () => {},
99
+ refreshWithResult: async () => "success",
100
+ };
101
+ },
102
+ useChatThreads: (...args: unknown[]) => {
103
+ clientState.lifecycleOptions(args[3]);
104
+ return {
105
+ threads: clientState.threads,
106
+ activeThreadId: "active-thread",
107
+ isLoading: false,
108
+ createThread: clientState.createThread,
109
+ switchThread: clientState.switchThread,
110
+ renameThread: clientState.renameThread,
111
+ pinThread: clientState.pinThread,
112
+ archiveThread: clientState.archiveThread,
113
+ isNewThread: clientState.isNewThread,
114
+ refreshThreads: vi.fn(),
115
+ };
116
+ },
82
117
  useChatModels: () => ({
83
118
  availableModels: [],
84
119
  defaultModel: "auto",
@@ -89,6 +124,14 @@ vi.mock("@agent-native/core/client/agent-chat", async (importOriginal) => ({
89
124
  }),
90
125
  }));
91
126
 
127
+ vi.mock(
128
+ "@agent-native/toolkit/app/chat/ownership-history",
129
+ async () =>
130
+ import("../../../../toolkit/src/app/chat/ownership-history/index.tsx"),
131
+ );
132
+
133
+ vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
134
+
92
135
  vi.mock("@agent-native/toolkit/app/chat/AgentSidebar", () => ({
93
136
  AgentToggleButton: () => null,
94
137
  AgentSidebar: ({ children }: { children: React.ReactNode }) => (
@@ -100,6 +143,9 @@ vi.mock("@agent-native/toolkit/app/chat/AgentSidebar", () => ({
100
143
  vi.mock("../deferred-chat-components.js", async (importOriginal) => ({
101
144
  ...(await importOriginal<typeof import("../deferred-chat-components.js")>()),
102
145
  AgentChatSurface: () => null,
146
+ ChatFirstChatHistory: (
147
+ await import("../../../../toolkit/src/app/chat/chat-first/chat-history.tsx")
148
+ ).ChatFirstChatHistory,
103
149
  }));
104
150
 
105
151
  vi.mock("@agent-native/core/client/api-path", () => ({
@@ -173,6 +219,8 @@ vi.mock("next-themes", () => ({
173
219
  }));
174
220
 
175
221
  vi.mock("@agent-native/core/client/i18n", () => ({
222
+ DEFAULT_LOCALE: "en-US",
223
+ useOptionalLocale: () => ({ locale: "en-US" }),
176
224
  useFormatters: () => ({ formatNumber: (value: number) => String(value) }),
177
225
  useT: () => (key: string, values?: Record<string, unknown>) => {
178
226
  const messages: Record<string, string> = {
@@ -183,6 +231,24 @@ vi.mock("@agent-native/core/client/i18n", () => ({
183
231
  "dispatch.pages.workspaceApps": "Workspace apps",
184
232
  "dispatch.nav.operate": "Operate",
185
233
  "dispatch.nav.advanced": "Advanced",
234
+ "agentChat.history.mine": "My chats",
235
+ "agentChat.history.sharedWithYou": "Shared with you",
236
+ "agentChat.history.ownedBy": `Owner: ${values?.name ?? ""}`,
237
+ "agentChat.history.accessDirect": "Shared with you",
238
+ "agentChat.history.accessOrganization": "Shared with your organization",
239
+ "agentChat.history.copyLink": "Copy link",
240
+ "agentChat.history.showAll": "Show all",
241
+ "agentChat.history.showLess": "Show less",
242
+ "agentChat.history.loadMore": "Load more",
243
+ "agentChat.history.loadingMore": "Loading...",
244
+ "agentChat.history.emptyMine": "No chats yet",
245
+ "dispatch.sidebar.chats": "Chats",
246
+ "dispatch.sidebar.showLocalChats": "Show local chats",
247
+ "dispatch.sidebar.showAllChats": "Show all chats",
248
+ "agentChat.contextXray.pin": "Pin",
249
+ "agentChat.contextXray.unpin": "Unpin",
250
+ "extensions.delete": "Archive",
251
+ "dispatch.integrations.failedToSave": "Failed to save",
186
252
  "dispatch.sidebar.newChat": "New chat",
187
253
  "dispatch.sidebar.newDispatchChat": "New Dispatch chat",
188
254
  "dispatch.sidebar.renameChat": "Rename chat",
@@ -248,20 +314,6 @@ describe("Dispatch navigation paths", () => {
248
314
  });
249
315
  });
250
316
 
251
- describe("formatThreadAge", () => {
252
- const now = 2_000_000_000_000;
253
-
254
- it.each([
255
- [0, "now"],
256
- [2 * 60 * 60_000, "2h"],
257
- [7 * 24 * 60 * 60_000, "7d"],
258
- [21 * 24 * 60 * 60_000, "3w"],
259
- [365 * 24 * 60 * 60_000, "1y"],
260
- ])("formats %i milliseconds as %s", (elapsed, expected) => {
261
- expect(formatThreadAge(now - elapsed, now)).toBe(expected);
262
- });
263
- });
264
-
265
317
  describe("chat-first embed sessions", () => {
266
318
  it("keeps the granted app id on app-relative embed requests", () => {
267
319
  expect(buildChatFirstEmbedSessionInput("mail", "/mail/inbox")).toEqual({
@@ -347,8 +399,26 @@ describe("Dispatch NavContent", () => {
347
399
  });
348
400
  clientState.createThread.mockResolvedValue("new-thread");
349
401
  clientState.switchThread.mockReset();
402
+ clientState.createThread.mockClear();
403
+ clientState.renameThread.mockReset().mockResolvedValue(true);
404
+ clientState.pinThread.mockReset().mockResolvedValue(true);
405
+ clientState.archiveThread.mockReset().mockResolvedValue(true);
406
+ clientState.sharedThreads = [];
407
+ clientState.mineThreads = null;
408
+ clientState.sharedHasMore = false;
409
+ clientState.loadMore.mockClear();
410
+ clientState.listQueries.mockClear();
411
+ clientState.lifecycleOptions.mockClear();
412
+ localStorage.clear();
413
+ vi.stubGlobal(
414
+ "fetch",
415
+ vi.fn().mockResolvedValue(new Response("{}", { status: 200 })),
416
+ );
350
417
  clientState.threads = [
351
418
  {
419
+ ownerEmail: "probe@example.com",
420
+ accessBasis: "owner",
421
+ visibility: "private",
352
422
  id: "active-thread",
353
423
  title: "Current Dispatch work",
354
424
  messageCount: 2,
@@ -356,6 +426,9 @@ describe("Dispatch NavContent", () => {
356
426
  createdAt: Date.now(),
357
427
  },
358
428
  {
429
+ ownerEmail: "probe@example.com",
430
+ accessBasis: "owner",
431
+ visibility: "private",
359
432
  id: "older-thread",
360
433
  title: "Earlier Dispatch work",
361
434
  messageCount: 1,
@@ -1166,6 +1239,255 @@ describe("Dispatch NavContent", () => {
1166
1239
  expect(paths).toEqual(["/apps/mail"]);
1167
1240
  });
1168
1241
 
1242
+ async function renderHistory(
1243
+ chatFirstMode = false,
1244
+ path = "/chat/active-thread",
1245
+ ) {
1246
+ await act(async () => {
1247
+ root.render(
1248
+ <MemoryRouter initialEntries={[path]}>
1249
+ <TooltipProvider>
1250
+ <NavContent chatFirstMode={chatFirstMode} />
1251
+ </TooltipProvider>
1252
+ </MemoryRouter>,
1253
+ );
1254
+ });
1255
+ }
1256
+
1257
+ async function clickText(scope: ParentNode, text: string) {
1258
+ const button = [
1259
+ ...scope.querySelectorAll<HTMLButtonElement>("button"),
1260
+ ].find((button) => button.textContent?.trim() === text);
1261
+ expect(button, text).toBeDefined();
1262
+ await act(async () => {
1263
+ button!.click();
1264
+ });
1265
+ }
1266
+
1267
+ async function openThreadMenu(title: string) {
1268
+ const row = [
1269
+ ...container.querySelectorAll<HTMLElement>(".an-chat-history-row"),
1270
+ ].find((row) => row.textContent?.includes(title));
1271
+ expect(row).toBeDefined();
1272
+ await act(async () => {
1273
+ row!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
1274
+ });
1275
+ }
1276
+
1277
+ it.each([false, true])(
1278
+ "keeps the proven-owned active empty chat and hides inactive blanks (chat-first %s)",
1279
+ async (chatFirstMode) => {
1280
+ clientState.mineThreads = [];
1281
+ clientState.threads = clientState.threads.map((thread) => ({
1282
+ ...thread,
1283
+ messageCount: 0,
1284
+ }));
1285
+ await renderHistory(chatFirstMode, "/chat");
1286
+
1287
+ expect(container.textContent).toContain("Current Dispatch work");
1288
+ expect(container.textContent).not.toContain("Earlier Dispatch work");
1289
+ expect(container.querySelectorAll(".an-chat-history-row")).toHaveLength(
1290
+ 1,
1291
+ );
1292
+ expect(clientState.switchThread).not.toHaveBeenCalled();
1293
+ expect(clientState.lifecycleOptions).toHaveBeenLastCalledWith({
1294
+ autoCreate: false,
1295
+ includeExternal: false,
1296
+ });
1297
+ },
1298
+ );
1299
+
1300
+ it.each(
1301
+ [false, true].flatMap((chatFirstMode) =>
1302
+ ["direct", "organization", "unproven"].map(
1303
+ (basis) => [chatFirstMode, basis] as const,
1304
+ ),
1305
+ ),
1306
+ )(
1307
+ "does not promote an active empty %s/%s lifecycle row into Mine",
1308
+ async (chatFirstMode, basis) => {
1309
+ clientState.mineThreads = [];
1310
+ const active = clientState.threads[0];
1311
+ clientState.threads = [
1312
+ {
1313
+ ...active,
1314
+ messageCount: 0,
1315
+ accessBasis: basis === "unproven" ? undefined : basis,
1316
+ ownerEmail:
1317
+ basis === "unproven" ? undefined : "colleague@example.com",
1318
+ visibility: basis === "unproven" ? undefined : "organization",
1319
+ },
1320
+ ];
1321
+ await renderHistory(chatFirstMode);
1322
+
1323
+ expect(container.textContent).not.toContain("Current Dispatch work");
1324
+ expect(container.querySelector(".an-chat-history-row")).toBeNull();
1325
+ expect(clientState.switchThread).not.toHaveBeenCalled();
1326
+ },
1327
+ );
1328
+
1329
+ it.each([false, true])(
1330
+ "renders server Shared attribution, five-row disclosure and paging (chat-first %s)",
1331
+ async (chatFirstMode) => {
1332
+ clientState.sharedThreads = Array.from({ length: 6 }, (_, index) => ({
1333
+ id: `shared-${index}`,
1334
+ title: `Shared work ${index}`,
1335
+ preview: "",
1336
+ messageCount: 1,
1337
+ createdAt: Date.now(),
1338
+ updatedAt: Date.now(),
1339
+ ownerEmail: "colleague@example.com",
1340
+ accessBasis: index === 0 ? "direct" : "organization",
1341
+ visibility: "organization",
1342
+ pinnedAt: Date.now(),
1343
+ }));
1344
+ clientState.sharedHasMore = true;
1345
+ await renderHistory(chatFirstMode, "/chat/shared-0");
1346
+ const shared = container.querySelector(
1347
+ '[data-section-id="shared"].an-chat-history__section',
1348
+ )!;
1349
+ expect(shared).not.toBeNull();
1350
+ expect(shared.querySelectorAll(".an-chat-history-row")).toHaveLength(5);
1351
+ expect(
1352
+ shared.querySelector('[role="img"]')?.getAttribute("aria-label"),
1353
+ ).toBe("Owner: colleague@example.com");
1354
+ for (const basis of ["direct", "organization"]) {
1355
+ expect(
1356
+ shared.querySelector(
1357
+ `.an-chat-history-row__owner-mark[data-access-basis="${basis}"]`,
1358
+ ),
1359
+ ).not.toBeNull();
1360
+ }
1361
+ expect(shared.querySelector(".an-chat-history-row--pinned")).toBeNull();
1362
+ expect(
1363
+ shared.querySelector(".an-chat-history-row--active")?.textContent,
1364
+ ).toContain("Shared work 0");
1365
+ await clickText(shared, "Show all");
1366
+ expect(shared.querySelectorAll(".an-chat-history-row")).toHaveLength(6);
1367
+ clientState.sharedThreads = clientState.sharedThreads.map((row) => ({
1368
+ ...row,
1369
+ }));
1370
+ await renderHistory(chatFirstMode, "/chat/shared-0");
1371
+ expect(shared.querySelectorAll(".an-chat-history-row")).toHaveLength(6);
1372
+ expect(
1373
+ shared.querySelector(".an-chat-history-row--active")?.textContent,
1374
+ ).toContain("Shared work 0");
1375
+ expect(clientState.switchThread).not.toHaveBeenCalled();
1376
+ await clickText(shared, "Load more");
1377
+ expect(clientState.loadMore).toHaveBeenCalledOnce();
1378
+ await clickText(shared, "Show less");
1379
+ expect(shared.querySelectorAll(".an-chat-history-row")).toHaveLength(5);
1380
+ await act(async () => {
1381
+ shared
1382
+ .querySelector<HTMLButtonElement>(".an-chat-history-row__button")!
1383
+ .click();
1384
+ });
1385
+ expect(clientState.switchThread).toHaveBeenCalledWith("shared-0");
1386
+ expect(clientState.lifecycleOptions).toHaveBeenLastCalledWith({
1387
+ autoCreate: false,
1388
+ includeExternal: false,
1389
+ });
1390
+ await openThreadMenu("Shared work 0");
1391
+ expect(
1392
+ document.body.querySelector(".an-chat-history-row__menu-owner")
1393
+ ?.textContent,
1394
+ ).toContain("colleague@example.comShared with you");
1395
+ const actions = [
1396
+ ...document.body.querySelectorAll(".an-chat-history-row__menu-item"),
1397
+ ].map((item) => item.textContent);
1398
+ expect(actions).toContain("Copy link");
1399
+ for (const ownerAction of ["Rename chat", "Pin", "Unpin", "Archive"]) {
1400
+ expect(actions).not.toContain(ownerAction);
1401
+ }
1402
+ },
1403
+ );
1404
+
1405
+ it.each([false, true])(
1406
+ "passes the persisted external source toggle to both ownership queries (chat-first %s)",
1407
+ async (chatFirstMode) => {
1408
+ await renderHistory(chatFirstMode);
1409
+ const toggle = container.querySelector<HTMLButtonElement>(
1410
+ "[data-dispatch-chat-source-toggle]",
1411
+ )!;
1412
+ expect(toggle.getAttribute("aria-pressed")).toBe("false");
1413
+ await act(async () => toggle.click());
1414
+ for (const ownership of ["mine", "shared"]) {
1415
+ expect(clientState.listQueries).toHaveBeenCalledWith(
1416
+ expect.objectContaining({ ownership, includeExternal: true }),
1417
+ );
1418
+ }
1419
+ expect(localStorage.getItem("dispatch.chat-history.source")).toBe("all");
1420
+ },
1421
+ );
1422
+
1423
+ it.each([false, true])(
1424
+ "archives the active owned chat before opening New chat (chat-first %s)",
1425
+ async (chatFirstMode) => {
1426
+ let settle!: (value: boolean) => void;
1427
+ clientState.archiveThread.mockReturnValue(
1428
+ new Promise<boolean>((resolve) => {
1429
+ settle = resolve;
1430
+ }),
1431
+ );
1432
+ await renderHistory(chatFirstMode);
1433
+ await openThreadMenu("Current Dispatch work");
1434
+ await clickText(document.body, "Archive");
1435
+ expect(clientState.archiveThread).toHaveBeenCalledWith("active-thread");
1436
+ expect(clientState.createThread).not.toHaveBeenCalled();
1437
+ await act(async () => settle(true));
1438
+ expect(clientState.createThread).toHaveBeenCalledOnce();
1439
+ expect(clientState.switchThread).toHaveBeenCalledWith("new-thread");
1440
+ },
1441
+ );
1442
+
1443
+ it.each([false, true])(
1444
+ "rolls back a failed archive, toasts once and keeps the active chat (chat-first %s)",
1445
+ async (chatFirstMode) => {
1446
+ vi.mocked(toast.error).mockClear();
1447
+ clientState.archiveThread.mockResolvedValue(false);
1448
+ await renderHistory(chatFirstMode);
1449
+ await openThreadMenu("Current Dispatch work");
1450
+ await clickText(document.body, "Archive");
1451
+ expect(container.textContent).toContain("Current Dispatch work");
1452
+ expect(clientState.createThread).not.toHaveBeenCalled();
1453
+ expect(clientState.switchThread).not.toHaveBeenCalled();
1454
+ expect(toast.error).toHaveBeenCalledExactlyOnceWith("Failed to save");
1455
+ },
1456
+ );
1457
+
1458
+ it.each([false, true])(
1459
+ "pins and renames owned rows through the adapter (chat-first %s)",
1460
+ async (chatFirstMode) => {
1461
+ await renderHistory(chatFirstMode);
1462
+ await openThreadMenu("Earlier Dispatch work");
1463
+ await clickText(document.body, "Pin");
1464
+ expect(clientState.pinThread).toHaveBeenCalledWith("older-thread", true);
1465
+ await openThreadMenu("Earlier Dispatch work");
1466
+ await clickText(document.body, "Rename chat");
1467
+ const input = container.querySelector<HTMLInputElement>(
1468
+ ".an-chat-history-row__rename-input",
1469
+ )!;
1470
+ await act(async () => {
1471
+ Object.getOwnPropertyDescriptor(
1472
+ HTMLInputElement.prototype,
1473
+ "value",
1474
+ )!.set!.call(input, "Revised work");
1475
+ input.dispatchEvent(new Event("input", { bubbles: true }));
1476
+ input.dispatchEvent(new Event("change", { bubbles: true }));
1477
+ });
1478
+ await act(async () => {
1479
+ input.dispatchEvent(
1480
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
1481
+ );
1482
+ });
1483
+ expect(clientState.renameThread).toHaveBeenCalledWith(
1484
+ "older-thread",
1485
+ "Revised work",
1486
+ );
1487
+ expect(clientState.createThread).not.toHaveBeenCalled();
1488
+ },
1489
+ );
1490
+
1169
1491
  it("uses the shared chat history rail and retains thread actions", async () => {
1170
1492
  await act(async () => {
1171
1493
  root.render(
@@ -1181,7 +1503,7 @@ describe("Dispatch NavContent", () => {
1181
1503
  expect(container.textContent).toContain("Current Dispatch work");
1182
1504
  expect(container.textContent).toContain("Earlier Dispatch work");
1183
1505
  expect(container.textContent).toContain("New chat");
1184
- expect(container.textContent).toContain("5m");
1506
+ expect(container.textContent).toContain("5m ago");
1185
1507
  expect(container.querySelector('[aria-label="Slack"]')).not.toBeNull();
1186
1508
  const sourceToggle = container.querySelector(
1187
1509
  "[data-dispatch-chat-source-toggle]",
@@ -1192,7 +1514,7 @@ describe("Dispatch NavContent", () => {
1192
1514
  });
1193
1515
  expect(sourceToggle.getAttribute("aria-pressed")).toBe("true");
1194
1516
  const age = [...container.querySelectorAll("span")].find(
1195
- (element) => element.textContent === "5m",
1517
+ (element) => element.textContent === "5m ago",
1196
1518
  );
1197
1519
  expect(age?.className).toContain("an-chat-history-row__timestamp");
1198
1520
  const historyList = container.querySelector(