@agent-native/dispatch 0.14.3 → 0.14.4

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 (127) hide show
  1. package/README.md +114 -128
  2. package/dist/actions/list_apps.d.ts +1 -0
  3. package/dist/actions/list_apps.js +16 -8
  4. package/dist/actions/list_apps.js.map +1 -1
  5. package/dist/actions/set-mcp-app-access.js +1 -4
  6. package/dist/actions/set-mcp-app-access.js.map +1 -1
  7. package/dist/components/action-query-error.d.ts +8 -0
  8. package/dist/components/action-query-error.d.ts.map +1 -0
  9. package/dist/components/action-query-error.js +10 -0
  10. package/dist/components/action-query-error.js.map +1 -0
  11. package/dist/components/app-keys-popover.d.ts +4 -0
  12. package/dist/components/app-keys-popover.d.ts.map +1 -1
  13. package/dist/components/app-keys-popover.js +26 -14
  14. package/dist/components/app-keys-popover.js.map +1 -1
  15. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  16. package/dist/components/dispatch-control-plane.js +31 -27
  17. package/dist/components/dispatch-control-plane.js.map +1 -1
  18. package/dist/components/index.d.ts +1 -0
  19. package/dist/components/index.d.ts.map +1 -1
  20. package/dist/components/index.js +1 -0
  21. package/dist/components/index.js.map +1 -1
  22. package/dist/components/layout/Layout.d.ts +1 -0
  23. package/dist/components/layout/Layout.d.ts.map +1 -1
  24. package/dist/components/layout/Layout.js +19 -21
  25. package/dist/components/layout/Layout.js.map +1 -1
  26. package/dist/components/ui/sheet.js +1 -1
  27. package/dist/components/ui/sheet.js.map +1 -1
  28. package/dist/components/ui/toast.js +1 -1
  29. package/dist/components/ui/toast.js.map +1 -1
  30. package/dist/components/workspace-app-card.d.ts.map +1 -1
  31. package/dist/components/workspace-app-card.js +8 -5
  32. package/dist/components/workspace-app-card.js.map +1 -1
  33. package/dist/components/workspace-resource-effective-stack.d.ts.map +1 -1
  34. package/dist/components/workspace-resource-effective-stack.js +6 -1
  35. package/dist/components/workspace-resource-effective-stack.js.map +1 -1
  36. package/dist/components/workspace-resource-impact-preview.d.ts.map +1 -1
  37. package/dist/components/workspace-resource-impact-preview.js +6 -1
  38. package/dist/components/workspace-resource-impact-preview.js.map +1 -1
  39. package/dist/routes/pages/$appId.d.ts.map +1 -1
  40. package/dist/routes/pages/$appId.js +6 -1
  41. package/dist/routes/pages/$appId.js.map +1 -1
  42. package/dist/routes/pages/agents.d.ts.map +1 -1
  43. package/dist/routes/pages/agents.js +5 -8
  44. package/dist/routes/pages/agents.js.map +1 -1
  45. package/dist/routes/pages/approval.d.ts.map +1 -1
  46. package/dist/routes/pages/approval.js +6 -1
  47. package/dist/routes/pages/approval.js.map +1 -1
  48. package/dist/routes/pages/approvals.d.ts.map +1 -1
  49. package/dist/routes/pages/approvals.js +8 -5
  50. package/dist/routes/pages/approvals.js.map +1 -1
  51. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  52. package/dist/routes/pages/apps.$appId.js +4 -2
  53. package/dist/routes/pages/apps.$appId.js.map +1 -1
  54. package/dist/routes/pages/apps.d.ts.map +1 -1
  55. package/dist/routes/pages/apps.js +9 -3
  56. package/dist/routes/pages/apps.js.map +1 -1
  57. package/dist/routes/pages/audit.d.ts.map +1 -1
  58. package/dist/routes/pages/audit.js +4 -2
  59. package/dist/routes/pages/audit.js.map +1 -1
  60. package/dist/routes/pages/chat.d.ts.map +1 -1
  61. package/dist/routes/pages/chat.js +16 -2
  62. package/dist/routes/pages/chat.js.map +1 -1
  63. package/dist/routes/pages/destinations.d.ts.map +1 -1
  64. package/dist/routes/pages/destinations.js +5 -3
  65. package/dist/routes/pages/destinations.js.map +1 -1
  66. package/dist/routes/pages/identities.d.ts.map +1 -1
  67. package/dist/routes/pages/identities.js +5 -3
  68. package/dist/routes/pages/identities.js.map +1 -1
  69. package/dist/routes/pages/integrations.d.ts.map +1 -1
  70. package/dist/routes/pages/integrations.js +9 -3
  71. package/dist/routes/pages/integrations.js.map +1 -1
  72. package/dist/routes/pages/thread-debug.d.ts.map +1 -1
  73. package/dist/routes/pages/thread-debug.js +7 -6
  74. package/dist/routes/pages/thread-debug.js.map +1 -1
  75. package/dist/routes/pages/vault.d.ts.map +1 -1
  76. package/dist/routes/pages/vault.js +23 -7
  77. package/dist/routes/pages/vault.js.map +1 -1
  78. package/dist/routes/pages/workspace.d.ts.map +1 -1
  79. package/dist/routes/pages/workspace.js +9 -3
  80. package/dist/routes/pages/workspace.js.map +1 -1
  81. package/dist/server/lib/dispatch-store.d.ts.map +1 -1
  82. package/dist/server/lib/dispatch-store.js +2 -2
  83. package/dist/server/lib/dispatch-store.js.map +1 -1
  84. package/dist/server/lib/mcp-access-store.d.ts.map +1 -1
  85. package/dist/server/lib/mcp-access-store.js +30 -2
  86. package/dist/server/lib/mcp-access-store.js.map +1 -1
  87. package/dist/server/plugins/agent-chat.d.ts +1 -1
  88. package/package.json +2 -2
  89. package/src/actions/list_apps.spec.ts +52 -0
  90. package/src/actions/list_apps.ts +16 -8
  91. package/src/actions/set-mcp-app-access.spec.ts +73 -0
  92. package/src/actions/set-mcp-app-access.ts +1 -5
  93. package/src/components/action-query-error.spec.tsx +56 -0
  94. package/src/components/action-query-error.tsx +32 -0
  95. package/src/components/app-keys-popover.spec.tsx +85 -0
  96. package/src/components/app-keys-popover.tsx +76 -50
  97. package/src/components/dispatch-control-plane.spec.tsx +106 -0
  98. package/src/components/dispatch-control-plane.tsx +71 -115
  99. package/src/components/index.ts +1 -0
  100. package/src/components/layout/Layout.spec.tsx +42 -2
  101. package/src/components/layout/Layout.tsx +22 -27
  102. package/src/components/ui/sheet.tsx +1 -1
  103. package/src/components/ui/toast.tsx +1 -1
  104. package/src/components/workspace-app-card.spec.tsx +80 -0
  105. package/src/components/workspace-app-card.tsx +36 -25
  106. package/src/components/workspace-resource-effective-stack.tsx +13 -1
  107. package/src/components/workspace-resource-impact-preview.tsx +12 -1
  108. package/src/routes/pages/$appId.tsx +17 -1
  109. package/src/routes/pages/agents.tsx +24 -11
  110. package/src/routes/pages/approval.tsx +16 -4
  111. package/src/routes/pages/approvals.tsx +108 -89
  112. package/src/routes/pages/apps.$appId.tsx +9 -2
  113. package/src/routes/pages/apps.tsx +20 -7
  114. package/src/routes/pages/audit.tsx +29 -20
  115. package/src/routes/pages/chat.spec.tsx +94 -0
  116. package/src/routes/pages/chat.tsx +30 -10
  117. package/src/routes/pages/destinations.tsx +75 -62
  118. package/src/routes/pages/identities.tsx +74 -63
  119. package/src/routes/pages/integrations.tsx +17 -10
  120. package/src/routes/pages/thread-debug.tsx +18 -12
  121. package/src/routes/pages/vault.tsx +59 -26
  122. package/src/routes/pages/workspace.tsx +69 -55
  123. package/src/server/lib/approval-fencing.spec.ts +113 -2
  124. package/src/server/lib/dispatch-store.ts +2 -0
  125. package/src/server/lib/mcp-access-store.spec.ts +116 -4
  126. package/src/server/lib/mcp-access-store.ts +37 -2
  127. package/src/server/lib/mcp-gateway.spec.ts +79 -3
@@ -1,76 +1,56 @@
1
1
  import {
2
2
  PromptComposer,
3
3
  isInBuilderFrame,
4
+ navigateWithAgentChatViewTransition,
4
5
  useActionQuery,
5
6
  useChatModels,
6
7
  useT,
7
8
  } from "@agent-native/core/client";
8
- import {
9
- IconArrowUpRight,
10
- IconBroadcast,
11
- IconStack3,
12
- type IconProps,
13
- } from "@tabler/icons-react";
9
+ import { IconArrowUpRight } from "@tabler/icons-react";
14
10
  import type { ReactNode } from "react";
15
11
  import { Link, useNavigate } from "react-router";
16
12
 
17
13
  import { submitOverviewPrompt } from "../lib/overview-chat";
18
14
  import type { WorkspaceAppSummary } from "../lib/workspace-apps";
15
+ import { ActionQueryError } from "./action-query-error";
19
16
  import { CreateAppPopover } from "./create-app-popover";
20
- import { DispatchShell } from "./dispatch-shell";
17
+ import { useSetPageTitle } from "./layout/HeaderActions";
21
18
  import { Button } from "./ui/button";
22
19
  import { Skeleton } from "./ui/skeleton";
23
20
  import { WorkspaceAppCard } from "./workspace-app-card";
24
21
 
25
- const PROMPT_SUGGESTIONS = [
26
- "Summarize the current workspace health",
27
- "Create an app for onboarding requests",
28
- "Check which agents can help with analytics",
29
- ];
30
-
31
- const WORKSPACE_LINKS = [
32
- { to: "/automations", labelKey: "dispatch.nav.automations" },
33
- { to: "/approvals", labelKey: "dispatch.nav.approvals" },
34
- { to: "/destinations", labelKey: "dispatch.nav.delivery" },
35
- { to: "/agents", labelKey: "dispatch.nav.agents" },
36
- { to: "/vault", labelKey: "dispatch.nav.vault" },
37
- { to: "/audit", labelKey: "dispatch.nav.audit" },
38
- ] as const;
39
-
40
22
  function SectionHeader({
41
- icon: Icon,
42
23
  title,
43
- detail,
44
24
  action,
45
25
  }: {
46
- icon: React.ComponentType<IconProps>;
47
26
  title: string;
48
- detail?: string;
49
27
  action?: ReactNode;
50
28
  }) {
51
29
  return (
52
30
  <div className="flex min-w-0 items-center justify-between gap-3">
53
- <div className="flex min-w-0 items-center gap-2">
54
- <Icon size={16} className="shrink-0 text-muted-foreground" />
55
- <div className="min-w-0">
56
- <h2 className="truncate text-sm font-semibold text-foreground">
57
- {title}
58
- </h2>
59
- {detail ? (
60
- <p className="mt-0.5 truncate text-xs text-muted-foreground">
61
- {detail}
62
- </p>
63
- ) : null}
64
- </div>
65
- </div>
31
+ <h2 className="truncate text-sm font-semibold text-foreground">
32
+ {title}
33
+ </h2>
66
34
  {action ? <div className="shrink-0">{action}</div> : null}
67
35
  </div>
68
36
  );
69
37
  }
70
38
 
71
39
  function CommandPanel() {
40
+ const t = useT();
72
41
  const { selectedModel } = useChatModels();
73
42
  const navigate = useNavigate();
43
+ const promptSuggestions = [
44
+ t("dispatch.pages.suggestionWorkspaceHealth", {
45
+ defaultValue: "Summarize the current workspace health",
46
+ }),
47
+ t("dispatch.pages.suggestionOnboardingApp", {
48
+ defaultValue: "Create an app for onboarding requests",
49
+ }),
50
+ t("dispatch.pages.suggestionAnalyticsAgents", {
51
+ defaultValue: "Check which agents can help with analytics",
52
+ }),
53
+ ];
74
54
 
75
55
  function send(message: string) {
76
56
  const trimmed = message.trim();
@@ -81,7 +61,7 @@ function CommandPanel() {
81
61
  return;
82
62
  }
83
63
 
84
- navigate("/chat", {
64
+ navigateWithAgentChatViewTransition(navigate, "/chat", {
85
65
  state: {
86
66
  dispatchPrompt: {
87
67
  id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
@@ -93,71 +73,44 @@ function CommandPanel() {
93
73
  }
94
74
 
95
75
  return (
96
- <section className="rounded-lg border bg-card p-4">
97
- <div className="mb-3 flex items-center justify-between gap-3">
98
- <div className="flex items-center gap-2">
99
- <IconBroadcast size={16} className="text-muted-foreground" />
100
- <h2 className="text-sm font-semibold text-foreground">
101
- Ask Dispatch
76
+ <section className="flex flex-col">
77
+ <div className="mx-auto flex w-full max-w-3xl flex-col pt-4 sm:pt-6">
78
+ <div className="mb-5 text-center">
79
+ <h2 className="text-2xl font-semibold tracking-tight text-foreground sm:text-3xl">
80
+ {t("dispatch.pages.chatAcrossApps", {
81
+ defaultValue: "Chat across your apps",
82
+ })}
102
83
  </h2>
84
+ <p className="mx-auto mt-2 max-w-xl text-sm leading-6 text-muted-foreground">
85
+ {t("dispatch.pages.chatAcrossAppsDescription", {
86
+ defaultValue:
87
+ "Route work, inspect status, or create something new from one place.",
88
+ })}
89
+ </p>
90
+ </div>
91
+ <PromptComposer
92
+ placeholder={t("dispatch.pages.overviewPromptPlaceholder", {
93
+ defaultValue: "Ask Dispatch anything...",
94
+ })}
95
+ onSubmit={(text) => send(text)}
96
+ />
97
+ <div className="mt-3 flex flex-wrap justify-center gap-2">
98
+ {promptSuggestions.map((suggestion) => (
99
+ <button
100
+ key={suggestion}
101
+ type="button"
102
+ onClick={() => send(suggestion)}
103
+ className="cursor-pointer rounded-md border border-transparent bg-muted/60 px-2.5 py-1.5 text-xs text-muted-foreground transition-[background-color,color] hover:bg-muted hover:text-foreground"
104
+ >
105
+ {suggestion}
106
+ </button>
107
+ ))}
103
108
  </div>
104
- <Button variant="outline" size="sm" asChild>
105
- <Link to="/chat">
106
- Open chat
107
- <IconArrowUpRight size={14} />
108
- </Link>
109
- </Button>
110
- </div>
111
- <PromptComposer
112
- placeholder="Route work, inspect status, or create an app..."
113
- onSubmit={(text) => send(text)}
114
- />
115
- <div className="mt-3 flex flex-wrap gap-2">
116
- {PROMPT_SUGGESTIONS.map((suggestion) => (
117
- <button
118
- key={suggestion}
119
- type="button"
120
- onClick={() => send(suggestion)}
121
- className="cursor-pointer rounded-md border border-border bg-background px-2.5 py-1.5 text-xs text-muted-foreground transition hover:border-foreground/30 hover:text-foreground"
122
- >
123
- {suggestion}
124
- </button>
125
- ))}
126
109
  </div>
127
110
  </section>
128
111
  );
129
112
  }
130
113
 
131
- function WorkspaceLinks() {
132
- const t = useT();
133
-
134
- return (
135
- <nav
136
- aria-label={t("dispatch.pages.workspaceShortcutsAria")}
137
- className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"
138
- >
139
- <span className="font-medium text-foreground/70">
140
- {t("dispatch.pages.also")}
141
- </span>
142
- {WORKSPACE_LINKS.map((item, index) => (
143
- <span key={item.to} className="inline-flex items-center gap-x-3">
144
- {index > 0 ? (
145
- <span aria-hidden className="text-border">
146
- ·
147
- </span>
148
- ) : null}
149
- <Link
150
- to={item.to}
151
- className="transition hover:text-foreground hover:underline underline-offset-4"
152
- >
153
- {t(item.labelKey)}
154
- </Link>
155
- </span>
156
- ))}
157
- </nav>
158
- );
159
- }
160
-
161
114
  function AppsPanel({
162
115
  apps,
163
116
  isLoading,
@@ -171,11 +124,7 @@ function AppsPanel({
171
124
  return (
172
125
  <section className="flex flex-col gap-3">
173
126
  <SectionHeader
174
- icon={IconStack3}
175
127
  title="Apps"
176
- detail={
177
- visibleApps.length === 1 ? "1 active" : `${visibleApps.length} active`
178
- }
179
128
  action={
180
129
  <Button variant="outline" size="sm" asChild>
181
130
  <Link to="/apps">
@@ -188,7 +137,10 @@ function AppsPanel({
188
137
  {showSkeletons ? (
189
138
  <div className="grid gap-3 md:grid-cols-2">
190
139
  {Array.from({ length: 4 }).map((_, index) => (
191
- <div key={index} className="rounded-lg border bg-card p-4">
140
+ <div
141
+ key={index}
142
+ className="rounded-xl border border-border/60 bg-card/40 p-4"
143
+ >
192
144
  <Skeleton className="h-4 w-32" />
193
145
  <Skeleton className="mt-3 h-3 w-24" />
194
146
  <Skeleton className="mt-3 h-3 w-full" />
@@ -209,20 +161,24 @@ function AppsPanel({
209
161
  }
210
162
 
211
163
  export function DispatchControlPlane() {
212
- const { data: workspaceApps = [], isLoading: appsLoading } = useActionQuery<
213
- WorkspaceAppSummary[]
214
- >("list-workspace-apps", { includeAgentCards: false, includeArchived: true });
164
+ useSetPageTitle(<span aria-hidden />);
165
+ const appsQuery = useActionQuery<WorkspaceAppSummary[]>(
166
+ "list-workspace-apps",
167
+ { includeAgentCards: false, includeArchived: true },
168
+ );
169
+ const { data: workspaceApps = [], isLoading: appsLoading } = appsQuery;
215
170
 
216
171
  return (
217
- <DispatchShell
218
- title="Overview"
219
- description="Ask Dispatch or jump into a workspace app."
220
- >
221
- <div className="flex flex-col gap-6">
222
- <CommandPanel />
223
- <WorkspaceLinks />
172
+ <div className="flex flex-col gap-8">
173
+ <CommandPanel />
174
+ {appsQuery.isError ? (
175
+ <ActionQueryError
176
+ error={appsQuery.error}
177
+ onRetry={() => void appsQuery.refetch()}
178
+ />
179
+ ) : (
224
180
  <AppsPanel apps={workspaceApps ?? []} isLoading={appsLoading} />
225
- </div>
226
- </DispatchShell>
181
+ )}
182
+ </div>
227
183
  );
228
184
  }
@@ -15,3 +15,4 @@ export type {
15
15
  } from "./layout/Layout.js";
16
16
  export { CreateAppPopover, CreateAppFlow } from "./create-app-popover.js";
17
17
  export { AppKeysPopover } from "./app-keys-popover.js";
18
+ export { ActionQueryError } from "./action-query-error.js";
@@ -5,7 +5,7 @@ import { MemoryRouter } from "react-router";
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
 
7
7
  import { TooltipProvider } from "../ui/tooltip";
8
- import { NavContent } from "./Layout";
8
+ import { formatThreadAge, NavContent } from "./Layout";
9
9
 
10
10
  const clientState = vi.hoisted(() => ({
11
11
  createThread: vi.fn<() => Promise<string | null>>(),
@@ -66,6 +66,20 @@ vi.mock("@agent-native/core/client/org", () => ({
66
66
  OrgSwitcher: () => <div>Organization</div>,
67
67
  }));
68
68
 
69
+ describe("formatThreadAge", () => {
70
+ const now = 2_000_000_000_000;
71
+
72
+ it.each([
73
+ [0, "now"],
74
+ [2 * 60 * 60_000, "2h"],
75
+ [7 * 24 * 60 * 60_000, "7d"],
76
+ [21 * 24 * 60 * 60_000, "3w"],
77
+ [365 * 24 * 60 * 60_000, "1y"],
78
+ ])("formats %i milliseconds as %s", (elapsed, expected) => {
79
+ expect(formatThreadAge(now - elapsed, now)).toBe(expected);
80
+ });
81
+ });
82
+
69
83
  describe("Dispatch NavContent", () => {
70
84
  let container: HTMLDivElement;
71
85
  let root: Root;
@@ -105,6 +119,25 @@ describe("Dispatch NavContent", () => {
105
119
  vi.unstubAllGlobals();
106
120
  });
107
121
 
122
+ it("puts Overview before Chat in the primary navigation", async () => {
123
+ await act(async () => {
124
+ root.render(
125
+ <MemoryRouter initialEntries={["/overview"]}>
126
+ <TooltipProvider>
127
+ <NavContent />
128
+ </TooltipProvider>
129
+ </MemoryRouter>,
130
+ );
131
+ });
132
+
133
+ const primaryLabels = [...container.querySelectorAll("nav a")].map((link) =>
134
+ link.textContent?.trim(),
135
+ );
136
+ expect(primaryLabels.indexOf("Overview")).toBeLessThan(
137
+ primaryLabels.indexOf("Chat"),
138
+ );
139
+ });
140
+
108
141
  it("uses the quieter Analytics-style chat history and retains thread actions", async () => {
109
142
  await act(async () => {
110
143
  root.render(
@@ -121,6 +154,13 @@ describe("Dispatch NavContent", () => {
121
154
  expect(container.textContent).toContain("Earlier Dispatch work");
122
155
  expect(container.textContent).toContain("New chat");
123
156
  expect(container.textContent).toContain("5m");
157
+ const age = [...container.querySelectorAll("time")].find(
158
+ (element) => element.textContent === "5m",
159
+ );
160
+ expect(age?.className).toContain("w-8");
161
+ expect(age?.className).toContain("shrink-0");
162
+ expect(age?.className).toContain("whitespace-nowrap");
163
+ expect(age?.className).toContain("tabular-nums");
124
164
  expect(
125
165
  [...container.querySelectorAll("div")].some((element) =>
126
166
  element.className.includes("group/item"),
@@ -133,7 +173,7 @@ describe("Dispatch NavContent", () => {
133
173
  expect(container.textContent).not.toContain("Workspace control plane");
134
174
 
135
175
  const threadButton = [...container.querySelectorAll("button")].find(
136
- (button) => button.textContent === "Earlier Dispatch work",
176
+ (button) => button.textContent?.includes("Earlier Dispatch work"),
137
177
  );
138
178
  expect(threadButton).toBeDefined();
139
179
  await act(async () => {
@@ -9,7 +9,6 @@ import {
9
9
  useAgentChatHomeHandoff,
10
10
  useAgentChatHomeHandoffLinks,
11
11
  useChatThreads,
12
- useFormatters,
13
12
  useT,
14
13
  type ChatThreadSummary,
15
14
  } from "@agent-native/core/client";
@@ -94,13 +93,6 @@ export interface DispatchExtensionConfig {
94
93
  }
95
94
 
96
95
  const PRIMARY_NAV_ITEMS = [
97
- {
98
- id: "chat",
99
- to: "/chat",
100
- label: "Chat",
101
- icon: IconMessageQuestion,
102
- section: "primary",
103
- },
104
96
  {
105
97
  id: "overview",
106
98
  to: "/overview",
@@ -108,6 +100,13 @@ const PRIMARY_NAV_ITEMS = [
108
100
  icon: IconBroadcast,
109
101
  section: "primary",
110
102
  },
103
+ {
104
+ id: "chat",
105
+ to: "/chat",
106
+ label: "Chat",
107
+ icon: IconMessageQuestion,
108
+ section: "primary",
109
+ },
111
110
  {
112
111
  id: "apps",
113
112
  to: "/apps",
@@ -312,22 +311,17 @@ function threadIdFromPath(pathname: string): string | null {
312
311
  }
313
312
  }
314
313
 
315
- function formatThreadAge(
316
- updatedAt: number,
317
- formatDate: ReturnType<typeof useFormatters>["formatDate"],
318
- ) {
319
- const diffMs = Math.max(0, Date.now() - updatedAt);
314
+ export function formatThreadAge(updatedAt: number, now = Date.now()) {
315
+ const diffMs = Math.max(0, now - updatedAt);
320
316
  const minutes = Math.floor(diffMs / 60_000);
321
317
  if (minutes < 1) return "now";
322
318
  if (minutes < 60) return `${minutes}m`;
323
319
  const hours = Math.floor(minutes / 60);
324
320
  if (hours < 24) return `${hours}h`;
325
321
  const days = Math.floor(hours / 24);
326
- if (days < 7) return `${days}d`;
327
- return formatDate(updatedAt, {
328
- month: "short",
329
- day: "numeric",
330
- });
322
+ if (days < 14) return `${days}d`;
323
+ if (days < 365) return `${Math.floor(days / 7)}w`;
324
+ return `${Math.floor(days / 365)}y`;
331
325
  }
332
326
 
333
327
  function threadTitle(thread: ChatThreadSummary, fallback: string) {
@@ -344,7 +338,6 @@ function threadUpdatedAt(thread: ChatThreadSummary) {
344
338
 
345
339
  function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
346
340
  const t = useT();
347
- const { formatDate } = useFormatters();
348
341
  const navigate = useNavigate();
349
342
  const location = useLocation();
350
343
  const {
@@ -510,19 +503,21 @@ function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
510
503
  <button
511
504
  type="button"
512
505
  onClick={() => openThread(thread.id)}
513
- className="min-w-0 flex-1 cursor-pointer px-2 py-1.5 pe-12 text-start text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
506
+ className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 px-2 py-1.5 pe-1 text-start text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
514
507
  >
515
- <span className="block min-w-0 truncate">{title}</span>
508
+ <span className="block min-w-0 flex-1 truncate">
509
+ {title}
510
+ </span>
511
+ <time className="w-8 shrink-0 whitespace-nowrap text-end text-[11px] tabular-nums text-muted-foreground/60 transition-opacity group-hover/item:opacity-0 group-focus-within/item:opacity-0">
512
+ {isActive
513
+ ? ""
514
+ : formatThreadAge(threadUpdatedAt(thread))}
515
+ </time>
516
516
  </button>
517
517
  </TooltipTrigger>
518
518
  <TooltipContent side="right">{title}</TooltipContent>
519
519
  </Tooltip>
520
520
  <div className="pointer-events-none absolute end-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5">
521
- <span className="pointer-events-none pe-1 text-[11px] text-muted-foreground/60 transition-opacity group-hover/item:opacity-0 group-focus-within/item:opacity-0">
522
- {isActive
523
- ? ""
524
- : formatThreadAge(threadUpdatedAt(thread), formatDate)}
525
- </span>
526
521
  <DropdownMenu>
527
522
  <Tooltip>
528
523
  <TooltipTrigger asChild>
@@ -532,7 +527,7 @@ function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
532
527
  aria-label={t("dispatch.sidebar.chatOptions", {
533
528
  title,
534
529
  })}
535
- className="pointer-events-auto rounded p-0.5 text-muted-foreground/50 opacity-0 transition-all hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover/item:opacity-100 group-focus-within/item:opacity-100 data-[state=open]:opacity-100 data-[state=open]:text-foreground"
530
+ className="pointer-events-auto rounded p-0.5 text-muted-foreground/50 opacity-0 transition-[color,opacity] hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover/item:opacity-100 group-focus-within/item:opacity-100 data-[state=open]:opacity-100 data-[state=open]:text-foreground"
536
531
  >
537
532
  <IconDots className="size-3" />
538
533
  </button>
@@ -29,7 +29,7 @@ const SheetOverlay = React.forwardRef<
29
29
  SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
30
30
 
31
31
  const sheetVariants = cva(
32
- "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
32
+ "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-150 data-[state=open]:duration-200",
33
33
  {
34
34
  variants: {
35
35
  side: {
@@ -23,7 +23,7 @@ const ToastViewport = React.forwardRef<
23
23
  ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
24
24
 
25
25
  const toastVariants = cva(
26
- "group pointer-events-auto relative flex w-full items-center justify-between gap-3 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
26
+ "group pointer-events-auto relative flex w-full items-center justify-between gap-3 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-transform motion-reduce:animate-none motion-reduce:transition-none data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
27
27
  {
28
28
  variants: {
29
29
  variant: {
@@ -0,0 +1,80 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import { TooltipProvider } from "./ui/tooltip";
7
+ import { WorkspaceAppCard } from "./workspace-app-card";
8
+
9
+ vi.mock("@agent-native/core/client", () => ({
10
+ useActionMutation: () => ({ mutate: vi.fn(), isPending: false }),
11
+ useActionQuery: () => ({
12
+ data: { resources: [], counts: {} },
13
+ isLoading: false,
14
+ isError: false,
15
+ error: null,
16
+ refetch: vi.fn(),
17
+ }),
18
+ }));
19
+
20
+ describe("WorkspaceAppCard", () => {
21
+ let container: HTMLDivElement;
22
+ let root: Root;
23
+
24
+ beforeEach(() => {
25
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
26
+ container = document.createElement("div");
27
+ document.body.appendChild(container);
28
+ root = createRoot(container);
29
+ });
30
+
31
+ afterEach(() => {
32
+ act(() => root.unmount());
33
+ container.remove();
34
+ vi.unstubAllGlobals();
35
+ });
36
+
37
+ it("uses one visible, consistent action treatment for context, keys, and more", async () => {
38
+ await act(async () => {
39
+ root.render(
40
+ <TooltipProvider>
41
+ <WorkspaceAppCard
42
+ app={{
43
+ id: "analytics",
44
+ name: "Analytics",
45
+ path: "/analytics",
46
+ description: "Explore product and growth performance.",
47
+ status: "ready",
48
+ }}
49
+ />
50
+ </TooltipProvider>,
51
+ );
52
+ });
53
+
54
+ expect(
55
+ container.querySelector('a[aria-label="Open Analytics"]')?.className,
56
+ ).toContain("focus-visible:ring-2");
57
+
58
+ const actions = [
59
+ container.querySelector<HTMLButtonElement>(
60
+ 'button[aria-label="View context resources for Analytics"]',
61
+ ),
62
+ container.querySelector<HTMLButtonElement>(
63
+ 'button[aria-label="Manage keys for Analytics"]',
64
+ ),
65
+ container.querySelector<HTMLButtonElement>(
66
+ 'button[aria-label="More actions for Analytics"]',
67
+ ),
68
+ ];
69
+
70
+ for (const action of actions) {
71
+ expect(action).not.toBeNull();
72
+ expect(action?.className).toContain("size-7");
73
+ expect(action?.className).toContain("text-muted-foreground");
74
+ expect(action?.className).toContain(
75
+ "transition-[background-color,color]",
76
+ );
77
+ expect(action?.className).not.toContain("opacity-0");
78
+ }
79
+ });
80
+ });