@agent-native/core 0.106.1 → 0.106.3

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 (99) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/docs/content/external-agents.mdx +8 -0
  4. package/corpus/core/docs/content/locales/ar-SA/external-agents.mdx +7 -0
  5. package/corpus/core/docs/content/locales/ar-SA/mcp-protocol.mdx +7 -0
  6. package/corpus/core/docs/content/locales/de-DE/external-agents.mdx +9 -0
  7. package/corpus/core/docs/content/locales/de-DE/mcp-protocol.mdx +9 -0
  8. package/corpus/core/docs/content/locales/es-ES/external-agents.mdx +9 -0
  9. package/corpus/core/docs/content/locales/es-ES/mcp-protocol.mdx +9 -0
  10. package/corpus/core/docs/content/locales/fr-FR/external-agents.mdx +9 -0
  11. package/corpus/core/docs/content/locales/fr-FR/mcp-protocol.mdx +9 -0
  12. package/corpus/core/docs/content/locales/hi-IN/external-agents.mdx +8 -0
  13. package/corpus/core/docs/content/locales/hi-IN/mcp-protocol.mdx +8 -0
  14. package/corpus/core/docs/content/locales/ja-JP/external-agents.mdx +8 -0
  15. package/corpus/core/docs/content/locales/ja-JP/mcp-protocol.mdx +8 -0
  16. package/corpus/core/docs/content/locales/ko-KR/external-agents.mdx +8 -0
  17. package/corpus/core/docs/content/locales/ko-KR/mcp-protocol.mdx +8 -0
  18. package/corpus/core/docs/content/locales/pt-BR/external-agents.mdx +9 -0
  19. package/corpus/core/docs/content/locales/pt-BR/mcp-protocol.mdx +9 -0
  20. package/corpus/core/docs/content/locales/zh-CN/external-agents.mdx +6 -0
  21. package/corpus/core/docs/content/locales/zh-CN/mcp-protocol.mdx +6 -0
  22. package/corpus/core/docs/content/locales/zh-TW/external-agents.mdx +6 -0
  23. package/corpus/core/docs/content/locales/zh-TW/mcp-protocol.mdx +6 -0
  24. package/corpus/core/docs/content/mcp-protocol.mdx +8 -0
  25. package/corpus/core/package.json +1 -1
  26. package/corpus/core/src/a2a/artifact-response.ts +84 -0
  27. package/corpus/core/src/agent/thread-data-builder.ts +1 -1
  28. package/corpus/core/src/integrations/webhook-handler.ts +41 -5
  29. package/corpus/core/src/mcp/builtin-tools.ts +115 -10
  30. package/corpus/core/src/server/agent-chat-plugin.ts +7 -2
  31. package/corpus/templates/clips/app/components/capture-install-options.tsx +60 -5
  32. package/corpus/templates/clips/app/components/library/library-layout.tsx +9 -1
  33. package/corpus/templates/clips/app/hooks/use-desktop-promo.ts +4 -4
  34. package/corpus/templates/clips/app/i18n/ar-SA.ts +1 -0
  35. package/corpus/templates/clips/app/i18n/de-DE.ts +1 -0
  36. package/corpus/templates/clips/app/i18n/en-US.ts +1 -0
  37. package/corpus/templates/clips/app/i18n/es-ES.ts +1 -0
  38. package/corpus/templates/clips/app/i18n/fr-FR.ts +1 -0
  39. package/corpus/templates/clips/app/i18n/hi-IN.ts +1 -0
  40. package/corpus/templates/clips/app/i18n/ja-JP.ts +1 -0
  41. package/corpus/templates/clips/app/i18n/ko-KR.ts +1 -0
  42. package/corpus/templates/clips/app/i18n/pt-BR.ts +1 -0
  43. package/corpus/templates/clips/app/i18n/zh-CN.ts +1 -0
  44. package/corpus/templates/clips/app/i18n/zh-TW.ts +1 -0
  45. package/corpus/templates/clips/app/lib/capture-install-options.ts +89 -4
  46. package/corpus/templates/clips/app/routes/_app.dictate.tsx +10 -1
  47. package/corpus/templates/clips/app/routes/record.tsx +1 -0
  48. package/corpus/templates/clips/app/routes/share.$shareId.tsx +7 -0
  49. package/corpus/templates/clips/changelog/2026-07-16-the-desktop-app-button-now-shows-open-desktop-app-and-launch.md +6 -0
  50. package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +5 -0
  51. package/corpus/templates/clips/desktop/src-tauri/capabilities/default.json +1 -0
  52. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +41 -6
  53. package/corpus/templates/clips/desktop/src-tauri/tauri.conf.json +5 -0
  54. package/corpus/templates/content/.agents/skills/content/SKILL.md +31 -0
  55. package/corpus/templates/content/changelog/2026-07-16-slack-corrections-now-preserve-newer-content-values-unless-y.md +6 -0
  56. package/dist/a2a/artifact-response.d.ts +9 -0
  57. package/dist/a2a/artifact-response.d.ts.map +1 -1
  58. package/dist/a2a/artifact-response.js +68 -0
  59. package/dist/a2a/artifact-response.js.map +1 -1
  60. package/dist/agent/thread-data-builder.js +1 -1
  61. package/dist/agent/thread-data-builder.js.map +1 -1
  62. package/dist/collab/struct-routes.d.ts +1 -1
  63. package/dist/integrations/webhook-handler.d.ts.map +1 -1
  64. package/dist/integrations/webhook-handler.js +24 -5
  65. package/dist/integrations/webhook-handler.js.map +1 -1
  66. package/dist/mcp/builtin-tools.d.ts.map +1 -1
  67. package/dist/mcp/builtin-tools.js +87 -6
  68. package/dist/mcp/builtin-tools.js.map +1 -1
  69. package/dist/notifications/routes.d.ts +1 -1
  70. package/dist/observability/routes.d.ts +5 -5
  71. package/dist/resources/handlers.d.ts +1 -1
  72. package/dist/secrets/routes.d.ts +9 -9
  73. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  74. package/dist/server/agent-chat-plugin.js +3 -1
  75. package/dist/server/agent-chat-plugin.js.map +1 -1
  76. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  77. package/docs/content/external-agents.mdx +8 -0
  78. package/docs/content/locales/ar-SA/external-agents.mdx +7 -0
  79. package/docs/content/locales/ar-SA/mcp-protocol.mdx +7 -0
  80. package/docs/content/locales/de-DE/external-agents.mdx +9 -0
  81. package/docs/content/locales/de-DE/mcp-protocol.mdx +9 -0
  82. package/docs/content/locales/es-ES/external-agents.mdx +9 -0
  83. package/docs/content/locales/es-ES/mcp-protocol.mdx +9 -0
  84. package/docs/content/locales/fr-FR/external-agents.mdx +9 -0
  85. package/docs/content/locales/fr-FR/mcp-protocol.mdx +9 -0
  86. package/docs/content/locales/hi-IN/external-agents.mdx +8 -0
  87. package/docs/content/locales/hi-IN/mcp-protocol.mdx +8 -0
  88. package/docs/content/locales/ja-JP/external-agents.mdx +8 -0
  89. package/docs/content/locales/ja-JP/mcp-protocol.mdx +8 -0
  90. package/docs/content/locales/ko-KR/external-agents.mdx +8 -0
  91. package/docs/content/locales/ko-KR/mcp-protocol.mdx +8 -0
  92. package/docs/content/locales/pt-BR/external-agents.mdx +9 -0
  93. package/docs/content/locales/pt-BR/mcp-protocol.mdx +9 -0
  94. package/docs/content/locales/zh-CN/external-agents.mdx +6 -0
  95. package/docs/content/locales/zh-CN/mcp-protocol.mdx +6 -0
  96. package/docs/content/locales/zh-TW/external-agents.mdx +6 -0
  97. package/docs/content/locales/zh-TW/mcp-protocol.mdx +6 -0
  98. package/docs/content/mcp-protocol.mdx +8 -0
  99. package/package.json +1 -1
@@ -123,8 +123,18 @@ interface AskAppTaskResult {
123
123
  arguments: { app: string; taskId: string };
124
124
  };
125
125
  message?: string;
126
+ statusRead?: "unavailable";
127
+ retryable?: true;
128
+ errorCategory?: AskAppStatusErrorCategory;
129
+ attempts?: number;
126
130
  }
127
131
 
132
+ type AskAppStatusErrorCategory =
133
+ | "transport"
134
+ | "timeout"
135
+ | "upstream_5xx"
136
+ | "rate_limited";
137
+
128
138
  function safeAppPath(raw: unknown): string | null {
129
139
  if (typeof raw !== "string" || !raw.trim()) return null;
130
140
  const value = raw.trim();
@@ -432,34 +442,125 @@ async function fetchAskAppA2ATask(
432
442
  taskId: string,
433
443
  ): Promise<AskAppTaskResult> {
434
444
  const { client } = await createA2AClientForAskApp(route.origin);
435
- let lastError: unknown;
445
+ const maxAttempts = ASK_APP_STATUS_RETRY_DELAYS_MS.length + 1;
436
446
  for (
437
447
  let attempt = 0;
438
448
  attempt <= ASK_APP_STATUS_RETRY_DELAYS_MS.length;
439
449
  attempt++
440
450
  ) {
451
+ const startedAt = Date.now();
441
452
  try {
442
453
  const task = await client.getTask(taskId);
443
454
  return askAppTaskResult(route, task);
444
455
  } catch (err) {
445
- lastError = err;
446
456
  const delayMs = ASK_APP_STATUS_RETRY_DELAYS_MS[attempt];
447
- if (delayMs == null || !isTransientAskAppStatusError(err)) {
457
+ const errorCategory = askAppStatusErrorCategory(err);
458
+ const retryable = errorCategory != null;
459
+ const willRetry = retryable && delayMs != null;
460
+ if (retryable) {
461
+ console.warn("[ask_app_status] tasks/get attempt failed", {
462
+ app: route.app,
463
+ routedVia: route.routedVia,
464
+ taskId,
465
+ originHost: askAppStatusOriginHost(route.origin),
466
+ attempt: attempt + 1,
467
+ maxAttempts,
468
+ elapsedMs: Date.now() - startedAt,
469
+ errorCategory,
470
+ errorName: err instanceof Error ? err.name : typeof err,
471
+ causeCode: askAppStatusErrorCauseCode(err),
472
+ willRetry,
473
+ });
474
+ }
475
+
476
+ if (!retryable) {
448
477
  throw err;
449
478
  }
479
+ if (delayMs == null) {
480
+ return askAppStatusReadUnavailableResult(
481
+ route,
482
+ taskId,
483
+ errorCategory,
484
+ maxAttempts,
485
+ );
486
+ }
450
487
  await new Promise((resolve) => setTimeout(resolve, delayMs));
451
488
  }
452
489
  }
453
- throw lastError;
490
+ throw new Error("ask_app_status retry loop exited unexpectedly.");
454
491
  }
455
492
 
456
493
  function isTransientAskAppStatusError(err: unknown): boolean {
494
+ return askAppStatusErrorCategory(err) != null;
495
+ }
496
+
497
+ function askAppStatusErrorCategory(
498
+ err: unknown,
499
+ ): AskAppStatusErrorCategory | null {
457
500
  const message = err instanceof Error ? err.message : String(err ?? "");
458
- return (
459
- /\bfetch failed\b|failed to fetch|networkerror|socket hang up|econnreset|etimedout|timeout|aborted/i.test(
460
- message,
461
- ) || /A2A request failed \((?:429|500|502|503|504)\)/i.test(message)
462
- );
501
+ const causeCode = askAppStatusErrorCauseCode(err) ?? "";
502
+ const diagnostic = `${message} ${causeCode}`;
503
+ if (/A2A request failed \(429\)/i.test(message)) return "rate_limited";
504
+ if (/A2A request failed \((?:500|502|503|504)\)/i.test(message)) {
505
+ return "upstream_5xx";
506
+ }
507
+ if (/etimedout|timeout|aborted|aborterror/i.test(diagnostic)) {
508
+ return "timeout";
509
+ }
510
+ if (
511
+ /\bfetch failed\b|failed to fetch|networkerror|socket hang up|econnreset/i.test(
512
+ diagnostic,
513
+ )
514
+ ) {
515
+ return "transport";
516
+ }
517
+ return null;
518
+ }
519
+
520
+ function askAppStatusErrorCauseCode(err: unknown): string | undefined {
521
+ if (!(err instanceof Error)) return undefined;
522
+ const directCode = (err as Error & { code?: unknown }).code;
523
+ if (typeof directCode === "string" && directCode.trim()) {
524
+ return directCode.trim();
525
+ }
526
+ if (!err.cause || typeof err.cause !== "object") return undefined;
527
+ const code = (err.cause as { code?: unknown }).code;
528
+ return typeof code === "string" && code.trim() ? code.trim() : undefined;
529
+ }
530
+
531
+ function askAppStatusOriginHost(origin: string): string {
532
+ try {
533
+ return new URL(origin).host;
534
+ } catch {
535
+ return "unknown";
536
+ }
537
+ }
538
+
539
+ function askAppStatusReadUnavailableResult(
540
+ route: AskAppRoute,
541
+ taskId: string,
542
+ errorCategory: AskAppStatusErrorCategory,
543
+ attempts: number,
544
+ ): AskAppTaskResult {
545
+ return {
546
+ app: route.app,
547
+ routedVia: route.routedVia,
548
+ taskId,
549
+ status: "unknown",
550
+ statusRead: "unavailable",
551
+ retryable: true,
552
+ errorCategory,
553
+ attempts,
554
+ pollAfterMs: ASK_APP_POLL_INTERVAL_MS,
555
+ poll: {
556
+ tool: "ask_app_status",
557
+ arguments: { app: route.app, taskId },
558
+ },
559
+ message:
560
+ "The durable ask_app task status could not be read after bounded retries. " +
561
+ "The task may still be running or completed. Retry ask_app_status " +
562
+ "with the same app and taskId; do not resubmit ask_app.",
563
+ };
463
564
  }
464
565
 
465
566
  /**
@@ -1186,7 +1287,11 @@ function askAppStatusTool(
1186
1287
  ): ActionEntry {
1187
1288
  return {
1188
1289
  tool: tool(
1189
- "Poll a durable ask_app task and return its current status or final response.",
1290
+ "Poll a durable ask_app task and return its current status or final response. " +
1291
+ "If a transient status read stays unavailable after bounded retries, the " +
1292
+ "result preserves the app and taskId with statusRead 'unavailable' and a " +
1293
+ "poll instruction. Retry ask_app_status for that same task; never resubmit " +
1294
+ "ask_app as status-read recovery because that can duplicate work.",
1190
1295
  {
1191
1296
  app: {
1192
1297
  type: "string",
@@ -13,7 +13,10 @@ import {
13
13
  type H3Event,
14
14
  } from "h3";
15
15
 
16
- import { buildA2ARecoverableArtifactMessage } from "../a2a/artifact-response.js";
16
+ import {
17
+ buildA2ARecoverableArtifactMessage,
18
+ type A2AToolResultSummary,
19
+ } from "../a2a/artifact-response.js";
17
20
  import {
18
21
  hasConfiguredA2ASecret,
19
22
  isLoopbackAddress,
@@ -1463,7 +1466,7 @@ export function createAgentChatPlugin(
1463
1466
  // Run the SAME agent loop, then extract the final answer from the
1464
1467
  // event stream so pre-tool narration never leaks as the A2A result.
1465
1468
  const a2aEvents: AgentChatEvent[] = [];
1466
- const a2aToolResults: Array<{ tool: string; result: string }> = [];
1469
+ const a2aToolResults: A2AToolResultSummary[] = [];
1467
1470
  let lastRecoverableArtifactText = "";
1468
1471
  const controller = new AbortController();
1469
1472
 
@@ -1498,6 +1501,8 @@ export function createAgentChatPlugin(
1498
1501
  a2aToolResults.push({
1499
1502
  tool: event.tool,
1500
1503
  result: event.result,
1504
+ isError: event.isError,
1505
+ completedSideEffect: event.completedSideEffect,
1501
1506
  });
1502
1507
  const recoverableArtifactText =
1503
1508
  buildA2ARecoverableArtifactMessage(a2aToolResults, {
@@ -7,7 +7,7 @@ import {
7
7
  IconDeviceDesktop,
8
8
  IconExternalLink,
9
9
  } from "@tabler/icons-react";
10
- import { type ReactNode } from "react";
10
+ import { type ReactNode, useSyncExternalStore } from "react";
11
11
 
12
12
  import { Button, type ButtonProps } from "@/components/ui/button";
13
13
  import {
@@ -16,11 +16,24 @@ import {
16
16
  PopoverTrigger,
17
17
  } from "@/components/ui/popover";
18
18
  import {
19
+ attemptOpenDesktopApp,
19
20
  clipsChromeExtensionEnabled,
20
21
  clipsChromeExtensionUrl,
22
+ hasDownloadedDesktopApp,
23
+ subscribeDownloaded,
21
24
  } from "@/lib/capture-install-options";
22
25
  import { cn } from "@/lib/utils";
23
26
 
27
+ // SSR snapshot is always false; same-tab markDesktopAppDownloaded() notifies
28
+ // subscribers so mounted CTAs flip to "Open" without a reload.
29
+ function useHasDownloadedDesktopApp(): boolean {
30
+ return useSyncExternalStore(
31
+ subscribeDownloaded,
32
+ hasDownloadedDesktopApp,
33
+ () => false,
34
+ );
35
+ }
36
+
24
37
  type PopoverPlacement = {
25
38
  align?: "start" | "center" | "end";
26
39
  side?: "top" | "right" | "bottom" | "left";
@@ -29,11 +42,15 @@ type PopoverPlacement = {
29
42
  type CaptureInstallButtonProps = Omit<ButtonProps, "asChild"> &
30
43
  PopoverPlacement & {
31
44
  children: ReactNode;
45
+ /** Label shown once the desktop app has been downloaded. */
46
+ downloadedChildren?: ReactNode;
32
47
  desktopHref?: string;
33
48
  };
34
49
 
35
50
  type CaptureInstallInlineLinkProps = PopoverPlacement & {
36
51
  children: ReactNode;
52
+ /** Label shown once the desktop app has been downloaded. */
53
+ downloadedChildren?: ReactNode;
37
54
  className?: string;
38
55
  desktopHref?: string;
39
56
  };
@@ -110,16 +127,37 @@ function InstallOptionsContent({ desktopHref = "/download" }) {
110
127
 
111
128
  export function CaptureInstallButton({
112
129
  children,
130
+ downloadedChildren,
113
131
  className,
114
132
  desktopHref = "/download",
115
133
  align = "end",
116
134
  side = "bottom",
117
135
  ...buttonProps
118
136
  }: CaptureInstallButtonProps) {
137
+ const downloaded = useHasDownloadedDesktopApp();
138
+ const label = downloaded ? (downloadedChildren ?? children) : children;
139
+
140
+ if (downloaded) {
141
+ const { onClick, ...restButtonProps } = buttonProps;
142
+ return (
143
+ <Button
144
+ className={className}
145
+ {...restButtonProps}
146
+ onClick={(event) => {
147
+ onClick?.(event);
148
+ if (event.defaultPrevented) return;
149
+ attemptOpenDesktopApp(desktopHref);
150
+ }}
151
+ >
152
+ {label}
153
+ </Button>
154
+ );
155
+ }
156
+
119
157
  if (!clipsChromeExtensionEnabled) {
120
158
  return (
121
159
  <Button asChild className={className} {...buttonProps}>
122
- <a href={appPath(desktopHref)}>{children}</a>
160
+ <a href={appPath(desktopHref)}>{label}</a>
123
161
  </Button>
124
162
  );
125
163
  }
@@ -128,7 +166,7 @@ export function CaptureInstallButton({
128
166
  <Popover>
129
167
  <PopoverTrigger asChild>
130
168
  <Button className={className} {...buttonProps}>
131
- {children}
169
+ {label}
132
170
  <IconChevronDown className="h-3.5 w-3.5" />
133
171
  </Button>
134
172
  </PopoverTrigger>
@@ -141,15 +179,32 @@ export function CaptureInstallButton({
141
179
 
142
180
  export function CaptureInstallInlineLink({
143
181
  children,
182
+ downloadedChildren,
144
183
  className,
145
184
  desktopHref = "/download",
146
185
  align = "start",
147
186
  side = "bottom",
148
187
  }: CaptureInstallInlineLinkProps) {
188
+ const downloaded = useHasDownloadedDesktopApp();
189
+
190
+ const label = downloaded ? (downloadedChildren ?? children) : children;
191
+
192
+ if (downloaded) {
193
+ return (
194
+ <button
195
+ type="button"
196
+ onClick={() => attemptOpenDesktopApp(desktopHref)}
197
+ className={cn("cursor-pointer", className)}
198
+ >
199
+ {label}
200
+ </button>
201
+ );
202
+ }
203
+
149
204
  if (!clipsChromeExtensionEnabled) {
150
205
  return (
151
206
  <a href={appPath(desktopHref)} className={className}>
152
- {children}
207
+ {label}
153
208
  </a>
154
209
  );
155
210
  }
@@ -158,7 +213,7 @@ export function CaptureInstallInlineLink({
158
213
  <Popover>
159
214
  <PopoverTrigger asChild>
160
215
  <button type="button" className={cn("cursor-pointer", className)}>
161
- {children}
216
+ {label}
162
217
  </button>
163
218
  </PopoverTrigger>
164
219
  <PopoverContent align={align} side={side} className="w-80 p-3">
@@ -528,7 +528,15 @@ export function LibraryLayout({ children }: LibraryLayoutProps) {
528
528
  <>
529
529
  <div className="shrink-0 space-y-1.5 px-2 py-1.5">
530
530
  {shouldShowSidebarLink && (
531
- <CaptureInstallInlineLink className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs text-foreground hover:bg-accent/60">
531
+ <CaptureInstallInlineLink
532
+ className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs text-foreground hover:bg-accent/60"
533
+ downloadedChildren={
534
+ <>
535
+ <IconAppWindow className="h-4 w-4" />
536
+ {t("captureInstall.openDesktopApp")}
537
+ </>
538
+ }
539
+ >
532
540
  <IconAppWindow className="h-4 w-4" />
533
541
  {t("navigation.desktopCta")}
534
542
  </CaptureInstallInlineLink>
@@ -2,8 +2,8 @@ import { useCallback, useEffect, useState } from "react";
2
2
 
3
3
  import { useIsMobile } from "@/hooks/use-mobile";
4
4
  import {
5
- hasDownloadedDesktopApp,
6
- markDesktopAppDownloaded,
5
+ hasDismissedDesktopPromo,
6
+ markDesktopPromoDismissed,
7
7
  } from "@/lib/capture-install-options";
8
8
 
9
9
  function detectDesktopApp(): boolean {
@@ -27,12 +27,12 @@ export function useDesktopPromo() {
27
27
 
28
28
  useEffect(() => {
29
29
  setIsDesktopApp(detectDesktopApp());
30
- setDismissed(hasDownloadedDesktopApp());
30
+ setDismissed(hasDismissedDesktopPromo());
31
31
  }, []);
32
32
 
33
33
  const dismiss = useCallback(() => {
34
34
  setDismissed(true);
35
- markDesktopAppDownloaded();
35
+ markDesktopPromoDismissed();
36
36
  }, []);
37
37
 
38
38
  return {
@@ -1106,6 +1106,7 @@ const messages = {
1106
1106
  desktopTitle: "Desktop app (مترجم)",
1107
1107
  desktopDescription:
1108
1108
  "Most seamless for global shortcuts, menu-bar recording, meetings, and repeat captures. (مترجم)",
1109
+ openDesktopApp: "Open desktop app (مترجم)",
1109
1110
  },
1110
1111
  editableTitle: {
1111
1112
  untitled: "Untitled Clip (مترجم)",
@@ -1132,6 +1132,7 @@ Alle sichtbaren Änderungen für Clips-Nutzer werden hier dokumentiert. Du kanns
1132
1132
  desktopTitle: "Desktop app (Lokalisiert)",
1133
1133
  desktopDescription:
1134
1134
  "Most seamless for global shortcuts, menu-bar recording, meetings, and repeat captures. (Lokalisiert)",
1135
+ openDesktopApp: "Open desktop app (Lokalisiert)",
1135
1136
  },
1136
1137
  editableTitle: {
1137
1138
  untitled: "Untitled Clip (Lokalisiert)",
@@ -1102,6 +1102,7 @@ All notable user-facing changes to Clips are documented here. Open it any time f
1102
1102
  desktopTitle: "Desktop app",
1103
1103
  desktopDescription:
1104
1104
  "Most seamless for global shortcuts, menu-bar recording, meetings, and repeat captures.",
1105
+ openDesktopApp: "Open desktop app",
1105
1106
  },
1106
1107
  editableTitle: {
1107
1108
  untitled: "Untitled Clip",
@@ -1125,6 +1125,7 @@ Todos los cambios visibles para los usuarios de Clips se documentan aquí. Puede
1125
1125
  desktopTitle: "Desktop app (Localizado)",
1126
1126
  desktopDescription:
1127
1127
  "Most seamless for global shortcuts, menu-bar recording, meetings, and repeat captures. (Localizado)",
1128
+ openDesktopApp: "Open desktop app (Localizado)",
1128
1129
  },
1129
1130
  editableTitle: {
1130
1131
  untitled: "Untitled Clip (Localizado)",
@@ -1127,6 +1127,7 @@ Tous les changements visibles par les utilisateurs de Clips sont documentés ici
1127
1127
  desktopTitle: "Desktop app (Localisé)",
1128
1128
  desktopDescription:
1129
1129
  "Most seamless for global shortcuts, menu-bar recording, meetings, and repeat captures. (Localisé)",
1130
+ openDesktopApp: "Open desktop app (Localisé)",
1130
1131
  },
1131
1132
  editableTitle: {
1132
1133
  untitled: "Untitled Clip (Localisé)",
@@ -1093,6 +1093,7 @@ Clips में उपयोगकर्ताओं को दिखने व
1093
1093
  desktopTitle: "Desktop app (स्थानीयकृत)",
1094
1094
  desktopDescription:
1095
1095
  "Most seamless for global shortcuts, menu-bar recording, meetings, and repeat captures. (स्थानीयकृत)",
1096
+ openDesktopApp: "Open desktop app (स्थानीयकृत)",
1096
1097
  },
1097
1098
  editableTitle: {
1098
1099
  untitled: "Untitled Clip (स्थानीयकृत)",
@@ -1114,6 +1114,7 @@ Clips のユーザー向けの主な変更はここに記録されます。コ
1114
1114
  desktopTitle: "Desktop app (ローカライズ済み)",
1115
1115
  desktopDescription:
1116
1116
  "Most seamless for global shortcuts, menu-bar recording, meetings, and repeat captures. (ローカライズ済み)",
1117
+ openDesktopApp: "Open desktop app (ローカライズ済み)",
1117
1118
  },
1118
1119
  editableTitle: {
1119
1120
  untitled: "Untitled Clip (ローカライズ済み)",
@@ -1103,6 +1103,7 @@ Clips의 모든 사용자 대상 변경 사항은 여기에 기록됩니다. 명
1103
1103
  desktopTitle: "Desktop app (현지화됨)",
1104
1104
  desktopDescription:
1105
1105
  "Most seamless for global shortcuts, menu-bar recording, meetings, and repeat captures. (현지화됨)",
1106
+ openDesktopApp: "Open desktop app (현지화됨)",
1106
1107
  },
1107
1108
  editableTitle: {
1108
1109
  untitled: "Untitled Clip (현지화됨)",
@@ -1121,6 +1121,7 @@ Todas as mudanças visíveis para usuários do Clips são documentadas aqui. Voc
1121
1121
  desktopTitle: "Desktop app (Localizado)",
1122
1122
  desktopDescription:
1123
1123
  "Most seamless for global shortcuts, menu-bar recording, meetings, and repeat captures. (Localizado)",
1124
+ openDesktopApp: "Open desktop app (Localizado)",
1124
1125
  },
1125
1126
  editableTitle: {
1126
1127
  untitled: "Untitled Clip (Localizado)",
@@ -1064,6 +1064,7 @@ Clips 中所有面向用户的重要更改都会记录在这里。你可以随
1064
1064
  desktopTitle: "Desktop app (已本地化)",
1065
1065
  desktopDescription:
1066
1066
  "Most seamless for global shortcuts, menu-bar recording, meetings, and repeat captures. (已本地化)",
1067
+ openDesktopApp: "Open desktop app (已本地化)",
1067
1068
  },
1068
1069
  editableTitle: {
1069
1070
  untitled: "Untitled Clip (已本地化)",
@@ -1055,6 +1055,7 @@ const messages = {
1055
1055
  "瀏覽器記錄選項已就緒,正在等待 Chrome Web Store URL。",
1056
1056
  desktopTitle: "桌面應用程式",
1057
1057
  desktopDescription: "最適合全域快捷鍵、選單列錄製、會議與重複擷取。",
1058
+ openDesktopApp: "開啟桌面應用程式",
1058
1059
  },
1059
1060
  editableTitle: {
1060
1061
  untitled: "未命名剪輯",
@@ -1,22 +1,107 @@
1
- const DESKTOP_DOWNLOAD_STORAGE_KEY = "clips.desktop-promo.dismissed";
1
+ import { appPath } from "@agent-native/core/client";
2
+
3
+ const DESKTOP_PROMO_DISMISSED_STORAGE_KEY = "clips.desktop-promo.dismissed";
4
+ const DESKTOP_DOWNLOADED_STORAGE_KEY = "clips.desktop-app.downloaded";
5
+
6
+ // Custom scheme the desktop build registers. A web click tries this first and
7
+ // falls back to the download page when nothing handles it.
8
+ const DESKTOP_APP_PROTOCOL_URL = "clips://open";
9
+ const DESKTOP_APP_LAUNCH_FALLBACK_MS = 800;
2
10
 
3
11
  export function hasDownloadedDesktopApp(): boolean {
4
12
  try {
13
+ if (typeof window === "undefined") return false;
14
+ const ls = window.localStorage;
15
+ // Also treat the legacy dismissed flag as "downloaded" — before the flag
16
+ // split, a single key covered both states, so existing users only have it.
5
17
  return (
6
- typeof window !== "undefined" &&
7
- window.localStorage?.getItem(DESKTOP_DOWNLOAD_STORAGE_KEY) === "1"
18
+ ls?.getItem(DESKTOP_DOWNLOADED_STORAGE_KEY) === "1" ||
19
+ ls?.getItem(DESKTOP_PROMO_DISMISSED_STORAGE_KEY) === "1"
8
20
  );
9
21
  } catch {
10
22
  return false;
11
23
  }
12
24
  }
13
25
 
26
+ const downloadedListeners = new Set<() => void>();
27
+
28
+ export function subscribeDownloaded(callback: () => void): () => void {
29
+ downloadedListeners.add(callback);
30
+ return () => {
31
+ downloadedListeners.delete(callback);
32
+ };
33
+ }
34
+
14
35
  export function markDesktopAppDownloaded(): void {
15
36
  try {
16
- window.localStorage?.setItem(DESKTOP_DOWNLOAD_STORAGE_KEY, "1");
37
+ // Downloading (or successfully launching) the app also hides the promo.
38
+ window.localStorage?.setItem(DESKTOP_DOWNLOADED_STORAGE_KEY, "1");
39
+ window.localStorage?.setItem(DESKTOP_PROMO_DISMISSED_STORAGE_KEY, "1");
17
40
  } catch {
18
41
  // Download tracking is best-effort and must not block the installer.
19
42
  }
43
+ downloadedListeners.forEach((fn) => fn());
44
+ }
45
+
46
+ export function hasDismissedDesktopPromo(): boolean {
47
+ try {
48
+ return (
49
+ typeof window !== "undefined" &&
50
+ window.localStorage?.getItem(DESKTOP_PROMO_DISMISSED_STORAGE_KEY) === "1"
51
+ );
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ export function markDesktopPromoDismissed(): void {
58
+ try {
59
+ window.localStorage?.setItem(DESKTOP_PROMO_DISMISSED_STORAGE_KEY, "1");
60
+ } catch {
61
+ // Dismiss tracking is best-effort.
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Try to launch the installed desktop app via its custom protocol, falling back
67
+ * to the download page when nothing handles the scheme. Browsers expose no way
68
+ * to query whether the protocol is registered, so we watch for the tab losing
69
+ * focus (the app taking over) within a short window; if that never happens we
70
+ * assume the app is not installed and navigate to the fallback. A successful
71
+ * launch self-heals the stored "downloaded" flag.
72
+ */
73
+ export function attemptOpenDesktopApp(fallbackHref = "/download"): void {
74
+ if (typeof window === "undefined") return;
75
+ const fallbackUrl = appPath(fallbackHref);
76
+
77
+ let launched = false;
78
+ const cleanup = () => {
79
+ window.removeEventListener("blur", onLeave);
80
+ document.removeEventListener("visibilitychange", onVisibility);
81
+ };
82
+ const onLeave = () => {
83
+ if (launched) return;
84
+ launched = true;
85
+ markDesktopAppDownloaded();
86
+ cleanup();
87
+ };
88
+ const onVisibility = () => {
89
+ if (document.hidden) onLeave();
90
+ };
91
+
92
+ window.addEventListener("blur", onLeave);
93
+ document.addEventListener("visibilitychange", onVisibility);
94
+
95
+ window.setTimeout(() => {
96
+ cleanup();
97
+ if (!launched) window.location.href = fallbackUrl;
98
+ }, DESKTOP_APP_LAUNCH_FALLBACK_MS);
99
+
100
+ try {
101
+ window.location.href = DESKTOP_APP_PROTOCOL_URL;
102
+ } catch {
103
+ // Some browsers throw on an unknown scheme; the timeout handles fallback.
104
+ }
20
105
  }
21
106
 
22
107
  function isFalsy(value: unknown): boolean {
@@ -640,7 +640,16 @@ function EmptyState({ isDesktopApp }: { isDesktopApp: boolean }) {
640
640
  {t("dictateRoute.emptyWebDescription")}
641
641
  </p>
642
642
  <div className="mt-5 flex items-center justify-center">
643
- <CaptureInstallButton size="sm" className="gap-1.5">
643
+ <CaptureInstallButton
644
+ size="sm"
645
+ className="gap-1.5"
646
+ downloadedChildren={
647
+ <>
648
+ <IconDeviceDesktop className="h-3.5 w-3.5" />
649
+ {t("captureInstall.openDesktopApp")}
650
+ </>
651
+ }
652
+ >
644
653
  <IconDownload className="h-3.5 w-3.5" />
645
654
  {t("dictateRoute.downloadDesktopApp")}
646
655
  </CaptureInstallButton>
@@ -670,6 +670,7 @@ function DesktopRecorderCallout() {
670
670
  <CaptureInstallButton
671
671
  size="sm"
672
672
  className="mt-4 w-full bg-primary text-primary-foreground hover:bg-primary/90"
673
+ downloadedChildren={t("captureInstall.openDesktopApp")}
673
674
  >
674
675
  {t("recordRoute.downloadDesktopApp")}
675
676
  </CaptureInstallButton>
@@ -11,6 +11,7 @@ import {
11
11
  import {
12
12
  IconAlertTriangle,
13
13
  IconArrowLeft,
14
+ IconDeviceDesktop,
14
15
  IconDownload,
15
16
  IconDots,
16
17
  IconExternalLink,
@@ -1220,6 +1221,12 @@ function PublicAgentEmptyState({
1220
1221
  className="w-full gap-2"
1221
1222
  align="center"
1222
1223
  onClick={() => onCtaClick("download")}
1224
+ downloadedChildren={
1225
+ <>
1226
+ <IconDeviceDesktop className="h-4 w-4" />
1227
+ {t("captureInstall.openDesktopApp")}
1228
+ </>
1229
+ }
1223
1230
  >
1224
1231
  <IconDownload className="h-4 w-4" />
1225
1232
  {downloadLabel}
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-07-16
4
+ ---
5
+
6
+ The desktop app button now shows "Open desktop app" and launches the installed app instead of re-downloading once you've downloaded it.
@@ -34,6 +34,11 @@ tauri = { version = "2", features = [
34
34
  tauri-plugin-shell = "2"
35
35
  tauri-plugin-global-shortcut = "2"
36
36
  tauri-plugin-single-instance = "2"
37
+ # Registers the `clips://` custom URL scheme so a web "Open desktop app"
38
+ # click can launch/focus the running tray app. macOS registration is baked
39
+ # into Info.plist at build time from `plugins.deep-link` in tauri.conf.json;
40
+ # Windows/Linux register at runtime via `register_all()`.
41
+ tauri-plugin-deep-link = "2"
37
42
  tauri-plugin-updater = "2"
38
43
  tauri-plugin-process = "2"
39
44
  tauri-plugin-autostart = "2.5.1"
@@ -28,6 +28,7 @@
28
28
  "core:window:allow-close",
29
29
  "core:window:allow-start-dragging",
30
30
  "shell:allow-open",
31
+ "deep-link:default",
31
32
  "global-shortcut:allow-register",
32
33
  "global-shortcut:allow-unregister",
33
34
  "global-shortcut:allow-is-registered",