@kortix/ui 0.14.4 → 0.14.5

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 (118) hide show
  1. package/README.md +8 -4
  2. package/dist/customize/sections/llm-provider/constants.d.ts +0 -1
  3. package/dist/customize/sections/llm-provider/constants.js +0 -2
  4. package/dist/customize/sections/llm-provider/models-tab-group.d.ts +1 -1
  5. package/dist/customize/sections/llm-provider/models-tab-group.js +7 -4
  6. package/dist/customize/sections/llm-provider/models-tab-row.d.ts +2 -18
  7. package/dist/customize/sections/llm-provider/models-tab-row.js +6 -30
  8. package/dist/customize/sections/llm-provider/models-tab.d.ts +0 -1
  9. package/dist/customize/sections/llm-provider/models-tab.js +1 -10
  10. package/dist/customize/sections/llm-provider/use-connected-providers.js +2 -2
  11. package/dist/customize/sections/llm-provider/utils.d.ts +1 -1
  12. package/dist/customize/sections/llm-provider/utils.js +7 -14
  13. package/dist/customize/sections/view/gateway/_shared.js +1 -2
  14. package/dist/customize/sections/view/gateway/gateway-routing.js +3 -2
  15. package/dist/customize/sections/view/sandbox-provider-coverage.d.ts +3 -18
  16. package/dist/customize/sections/view/sandbox-provider-coverage.js +7 -27
  17. package/dist/customize/settings/tabs/sandbox-tab.d.ts +2 -27
  18. package/dist/customize/settings/tabs/sandbox-tab.js +13 -112
  19. package/dist/customize/shared/entity/entity-modal.d.ts +5 -1
  20. package/dist/customize/shared/entity/entity-modal.js +7 -4
  21. package/dist/customize/skills/remove-skill-button.d.ts +16 -0
  22. package/dist/customize/skills/remove-skill-button.js +42 -0
  23. package/dist/customize/skills/skills-page.js +1 -1
  24. package/dist/customize/triggers/schedule/trigger-list.d.ts +0 -2
  25. package/dist/customize/triggers/schedule/trigger-list.js +1 -18
  26. package/dist/customize/triggers/schedule/trigger-tile.d.ts +1 -32
  27. package/dist/customize/triggers/schedule/trigger-tile.js +1 -16
  28. package/dist/files/project/hooks/use-change-requests.d.ts +14 -1
  29. package/dist/files/project/hooks/use-change-requests.js +44 -25
  30. package/dist/files/renderers/docx/docx-viewer.js +4 -1
  31. package/dist/files/renderers/pdf/pdf-renderer.d.ts +3 -1
  32. package/dist/files/renderers/pdf/pdf-renderer.js +2 -2
  33. package/dist/files/renderers/pdf/pdf-viewer.d.ts +3 -0
  34. package/dist/files/renderers/pdf/pdf-viewer.js +15 -6
  35. package/dist/files/viewer/file-content-renderer.d.ts +4 -1
  36. package/dist/files/viewer/file-content-renderer.js +2 -2
  37. package/dist/i18n/en.d.ts +26 -35
  38. package/dist/i18n/en.js +35 -44
  39. package/dist/index.d.ts +1 -1
  40. package/dist/internal/hooks/use-notification-center.js +21 -15
  41. package/dist/internal/hooks/use-review-session-summary.d.ts +6 -0
  42. package/dist/internal/hooks/use-review-session-summary.js +11 -0
  43. package/dist/internal/lib/browser-error-noise.d.ts +1 -1
  44. package/dist/internal/lib/browser-error-noise.js +1 -1
  45. package/dist/internal/lib/browser-noise/rules/rejections.d.ts +10 -0
  46. package/dist/internal/lib/browser-noise/rules/rejections.js +27 -1
  47. package/dist/internal/lib/provider-branding.js +14 -3
  48. package/dist/internal/lib/trigger-clock.d.ts +42 -0
  49. package/dist/internal/lib/trigger-clock.js +34 -0
  50. package/dist/internal/lib/web-notifications.d.ts +6 -0
  51. package/dist/internal/lib/web-notifications.js +57 -7
  52. package/dist/internal/notifications/notification-bell.d.ts +13 -0
  53. package/dist/internal/notifications/notification-bell.js +1 -1
  54. package/dist/internal/reminders/reminder-filter.d.ts +12 -0
  55. package/dist/internal/reminders/reminder-filter.js +34 -0
  56. package/dist/internal/reminders/reminder-format.d.ts +6 -0
  57. package/dist/internal/reminders/reminder-format.js +20 -2
  58. package/dist/internal/stores/kortix-computer-store.d.ts +1 -1
  59. package/dist/internal/stores/session-hover-store.d.ts +20 -5
  60. package/dist/internal/stores/session-hover-store.js +31 -13
  61. package/dist/internal/stores/session-panel-memory-store.js +2 -0
  62. package/dist/primitives/count-badge.d.ts +3 -1
  63. package/dist/primitives/count-badge.js +3 -3
  64. package/dist/primitives/glyph-paths-source.js +1 -1
  65. package/dist/primitives/glyph-paths.generated.js +1 -1
  66. package/dist/providers/ui-provider.d.ts +57 -0
  67. package/dist/providers/ui-provider.js +4 -0
  68. package/dist/session/composer/ComposerCard.d.ts +2 -0
  69. package/dist/session/composer/ComposerCard.js +5 -1
  70. package/dist/session/composer/agent-selector.js +12 -5
  71. package/dist/session/composer/composer-toolbar.js +4 -2
  72. package/dist/session/composer/composer-underbar.js +3 -1
  73. package/dist/session/composer/composer.js +34 -5
  74. package/dist/session/composer/menus/slash-actions.d.ts +22 -2
  75. package/dist/session/composer/menus/slash-actions.js +20 -0
  76. package/dist/session/composer/menus/slash-items.js +19 -3
  77. package/dist/session/header/reminder-parts.d.ts +285 -0
  78. package/dist/session/header/reminder-parts.js +115 -0
  79. package/dist/session/header/session-reminders-indicator.d.ts +13 -0
  80. package/dist/session/header/session-reminders-indicator.js +142 -35
  81. package/dist/session/models/model-availability.d.ts +23 -0
  82. package/dist/session/models/model-availability.js +20 -0
  83. package/dist/session/models/model-grouping.js +1 -4
  84. package/dist/session/models/model-selector.js +23 -14
  85. package/dist/session/models/use-model-connection-gate.js +4 -1
  86. package/dist/session/outcomes/session-outcomes-provider.d.ts +3 -1
  87. package/dist/session/outcomes/session-outcomes-provider.js +31 -3
  88. package/dist/session/overrides/session-overrides-composer.d.ts +1 -1
  89. package/dist/session/overrides/session-overrides-composer.js +5 -1
  90. package/dist/session/panel/mobile-tool-drawer.js +4 -2
  91. package/dist/session/panel/open-session-quick-view.js +2 -1
  92. package/dist/session/panel/panel-fallback.js +8 -4
  93. package/dist/session/panel/session-panel-provider.d.ts +1 -0
  94. package/dist/session/panel/session-panel-provider.js +13 -1
  95. package/dist/session/panel/session-reminders-panel.d.ts +4 -0
  96. package/dist/session/panel/session-reminders-panel.js +109 -0
  97. package/dist/session/panel/shared/use-panel-handoffs.d.ts +2 -2
  98. package/dist/session/panel/shared/use-panel-handoffs.js +5 -1
  99. package/dist/session/print/session-print-header.d.ts +0 -10
  100. package/dist/session/print/session-print-header.js +16 -1
  101. package/dist/session/status/session-surface.js +1 -1
  102. package/dist/session/thread/optimistic-turn.d.ts +5 -1
  103. package/dist/session/thread/optimistic-turn.js +8 -6
  104. package/dist/session/thread/session-chat.d.ts +6 -1
  105. package/dist/session/thread/session-chat.js +22 -3
  106. package/dist/session/thread/session-thread.d.ts +10 -1
  107. package/dist/session/thread/session-thread.js +2 -2
  108. package/dist/session/turn/burst-step-body.js +4 -0
  109. package/dist/styles.css +1 -1
  110. package/dist/workspace/sidebar/project-session-list.js +52 -55
  111. package/dist/workspace/sidebar/session-brief-hover-card.d.ts +35 -18
  112. package/dist/workspace/sidebar/session-brief-hover-card.js +114 -37
  113. package/dist/workspace/sidebar/session-status-mark.d.ts +15 -1
  114. package/dist/workspace/sidebar/session-status-mark.js +63 -17
  115. package/package.json +7 -3
  116. package/skills/kortix-ui/SKILL.md +1 -1
  117. package/dist/customize/sections/view/sandbox-provider-result.d.ts +0 -47
  118. package/dist/customize/sections/view/sandbox-provider-result.js +0 -79
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { qk, useFeatureFlag } from '@kortix/sdk/react';
3
- import { useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import { useQueryClient } from '@tanstack/react-query';
4
4
  import { useCallback, useState, useSyncExternalStore } from 'react';
5
5
  export const NOTIFICATION_CENTER_FLAG = 'notification_center';
6
6
  /** The predicate of `useFeatureFlag`: the server said exactly `true`. */
@@ -50,14 +50,14 @@ export function anyCachedNotificationCenter(client) {
50
50
  return client.getQueryCache().getAll().some(holdsFlagOn);
51
51
  }
52
52
  /**
53
- * `anyCachedNotificationCenter`, reactive. Subscribes to the cache only while
54
- * `scan`. The cache reports synchronously, also when another component's
53
+ * A `useSyncExternalStore` subscription to the query cache, live only while
54
+ * `active`. The cache reports synchronously, also when another component's
55
55
  * `useQuery` builds its query during render; the re-check waits for a
56
56
  * microtask so this component never updates in the middle of that render.
57
57
  */
58
- function useAnyCachedNotificationCenter(client, scan) {
59
- const subscribe = useCallback((onChange) => {
60
- if (!scan)
58
+ function useCacheSubscription(client, active) {
59
+ return useCallback((onChange) => {
60
+ if (!active)
61
61
  return () => { };
62
62
  let live = true;
63
63
  const unsubscribe = client.getQueryCache().subscribe(() => {
@@ -70,8 +70,11 @@ function useAnyCachedNotificationCenter(client, scan) {
70
70
  live = false;
71
71
  unsubscribe();
72
72
  };
73
- }, [client, scan]);
74
- return useSyncExternalStore(subscribe, () => scan && anyCachedNotificationCenter(client), () => false);
73
+ }, [client, active]);
74
+ }
75
+ /** `anyCachedNotificationCenter`, reactive. Subscribes to the cache only while `scan`. */
76
+ function useAnyCachedNotificationCenter(client, scan) {
77
+ return useSyncExternalStore(useCacheSubscription(client, scan), () => scan && anyCachedNotificationCenter(client), () => false);
75
78
  }
76
79
  /** Is the notification center on for `projectId`, or, without one, for any cached project. */
77
80
  export function useNotificationCenter(projectId) {
@@ -88,14 +91,17 @@ export function useNotificationCenter(projectId) {
88
91
  * load of a project page starts off.
89
92
  */
90
93
  export function useNotificationHostGate(projectId) {
91
- // The global host sits outside the access boundary. Observe the shell's
92
- // detail entry without starting a request for a potentially revoked project.
93
- const detail = useQuery({
94
- queryKey: qk.project.detail(projectId ?? ''),
95
- enabled: false,
96
- });
97
- const flag = { isLoading: detail.isPending, enabled: notificationCenterOn(detail.data?.project) };
98
94
  const client = useQueryClient();
95
+ // The global host sits outside the access boundary. Read the shell's detail
96
+ // entry from the cache: no request for a potentially revoked project, and no
97
+ // observer. An observer added a detail query without a queryFn (also for the
98
+ // empty id of a page outside any project), and every refetch of it logged
99
+ // "No queryFn was passed".
100
+ const detail = useSyncExternalStore(useCacheSubscription(client, !!projectId), () => (projectId ? client.getQueryState(qk.project.detail(projectId)) : undefined), () => undefined);
101
+ const flag = {
102
+ isLoading: !detail || detail.status === 'pending',
103
+ enabled: notificationCenterOn(detail?.data?.project),
104
+ };
99
105
  const anyProject = useAnyCachedNotificationCenter(client, !projectId);
100
106
  const [previous, setPrevious] = useState(false);
101
107
  let answer = anyProject;
@@ -51,3 +51,9 @@ export declare function useIsNeedsInputPoller(projectId: string): boolean;
51
51
  * `ProjectSessionList` alone still sees open questions.
52
52
  */
53
53
  export declare function useSessionNeedsYouCounts(projectId: string): Record<string, number>;
54
+ /**
55
+ * Sessions with an open agent question or a pending connector approval: the
56
+ * `needs-you` rows that wait on an answer rather than a change request. Reads
57
+ * the cache `useSessionNeedsYouCounts` already polls, so it adds no request.
58
+ */
59
+ export declare function useSessionsAwaitingAnswer(projectId: string): ReadonlySet<string>;
@@ -82,3 +82,14 @@ export function useSessionNeedsYouCounts(projectId) {
82
82
  const { data: needsInput } = useSessionsNeedingInput(projectId, { enabled: poller });
83
83
  return useMemo(() => withNeedsInput(needsYouBySession, needsInput), [needsYouBySession, needsInput]);
84
84
  }
85
+ /**
86
+ * Sessions with an open agent question or a pending connector approval: the
87
+ * `needs-you` rows that wait on an answer rather than a change request. Reads
88
+ * the cache `useSessionNeedsYouCounts` already polls, so it adds no request.
89
+ */
90
+ export function useSessionsAwaitingAnswer(projectId) {
91
+ const { data } = useSessionsNeedingInput(projectId, { enabled: false });
92
+ return useMemo(() => new Set(Object.entries(data?.sessions ?? {})
93
+ .filter(([, count]) => count > 0)
94
+ .map(([sessionId]) => sessionId)), [data]);
95
+ }
@@ -10,7 +10,7 @@ export { isConnectionClosedNoise, isFailedToSendMessageNoise, isFramelessNetwork
10
10
  export { isIosWebViewInjectedStackOverflowNoise, isOldBrowserDomNullDerefNoise, isFirefoxDeadObjectUnattributableNoise, isOldBrowserSyntaxParseError, isOldWebkitRegexNoiseMessage, isUndefinedVariableThirdPartyNoise, isUnresolvableStackOverflowNoise, } from './browser-noise/rules/old-browsers.js';
11
11
  export { isEmbedPdfTilingReactUpdateDepthNoise, isEmbedPdfTilingTileDestructureNoise, } from './browser-noise/rules/pdf.js';
12
12
  export { isDocumentStateNotFoundNoise, isExpectedNextRecoveryBailoutNoise, isFirefoxReactSchedulerReentryNoise, isLikelyDomMutationNoise, isServerSuspenseBailoutNoise, isThirdPartyReactUpdateDepthNoise, } from './browser-noise/rules/react.js';
13
- export { isAnonymousAuthRefreshRace, isAutoplayNotAllowedRejectionNoise, isNonErrorObjectNotFoundRejectionNoise, isNonErrorUndefinedRejectionNoise, isOperationErrorPopErrorScopeNoise, isSupabaseTokenExpiredNoise, } from './browser-noise/rules/rejections.js';
13
+ export { isAnonymousAuthRefreshRace, isAutoplayNotAllowedRejectionNoise, isNonErrorObjectNotFoundRejectionNoise, isNonErrorUndefinedRejectionNoise, isOperationErrorPopErrorScopeNoise, isPlayNotSupportedRejectionNoise, isSupabaseTokenExpiredNoise, } from './browser-noise/rules/rejections.js';
14
14
  export { isSafariGenericSecurityErrorNoise, isStorageDisabledWebViewNoiseMessage, isStorageSecurityErrorNoise, } from './browser-noise/rules/storage.js';
15
15
  export { isAndroidWebViewNativeBridgePostEventNoise, isAndroidWebViewNativeBridgePostMessageNoise, isIOSWebViewWebKitBridgeNoise, } from './browser-noise/rules/webview.js';
16
16
  export declare const NOISE_RULES: readonly NoiseRule[];
@@ -35,7 +35,7 @@ export { isConnectionClosedNoise, isFailedToSendMessageNoise, isFramelessNetwork
35
35
  export { isIosWebViewInjectedStackOverflowNoise, isOldBrowserDomNullDerefNoise, isFirefoxDeadObjectUnattributableNoise, isOldBrowserSyntaxParseError, isOldWebkitRegexNoiseMessage, isUndefinedVariableThirdPartyNoise, isUnresolvableStackOverflowNoise, } from './browser-noise/rules/old-browsers.js';
36
36
  export { isEmbedPdfTilingReactUpdateDepthNoise, isEmbedPdfTilingTileDestructureNoise, } from './browser-noise/rules/pdf.js';
37
37
  export { isDocumentStateNotFoundNoise, isExpectedNextRecoveryBailoutNoise, isFirefoxReactSchedulerReentryNoise, isLikelyDomMutationNoise, isServerSuspenseBailoutNoise, isThirdPartyReactUpdateDepthNoise, } from './browser-noise/rules/react.js';
38
- export { isAnonymousAuthRefreshRace, isAutoplayNotAllowedRejectionNoise, isNonErrorObjectNotFoundRejectionNoise, isNonErrorUndefinedRejectionNoise, isOperationErrorPopErrorScopeNoise, isSupabaseTokenExpiredNoise, } from './browser-noise/rules/rejections.js';
38
+ export { isAnonymousAuthRefreshRace, isAutoplayNotAllowedRejectionNoise, isNonErrorObjectNotFoundRejectionNoise, isNonErrorUndefinedRejectionNoise, isOperationErrorPopErrorScopeNoise, isPlayNotSupportedRejectionNoise, isSupabaseTokenExpiredNoise, } from './browser-noise/rules/rejections.js';
39
39
  export { isSafariGenericSecurityErrorNoise, isStorageDisabledWebViewNoiseMessage, isStorageSecurityErrorNoise, } from './browser-noise/rules/storage.js';
40
40
  export { isAndroidWebViewNativeBridgePostEventNoise, isAndroidWebViewNativeBridgePostMessageNoise, isIOSWebViewWebKitBridgeNoise, } from './browser-noise/rules/webview.js';
41
41
  export const NOISE_RULES = [
@@ -127,4 +127,14 @@ export declare function isAutoplayNotAllowedRejectionNoise(input: {
127
127
  filename?: unknown;
128
128
  } | undefined>;
129
129
  }): boolean;
130
+ /**
131
+ * Whether a Sentry event is the frameless `HTMLMediaElement.play()`
132
+ * `NotSupportedError` rejection. See `PLAY_NOT_SUPPORTED_PATTERN`.
133
+ */
134
+ export declare function isPlayNotSupportedRejectionNoise(input: {
135
+ message?: unknown;
136
+ frames?: Array<{
137
+ filename?: unknown;
138
+ } | undefined>;
139
+ }): boolean;
130
140
  export declare const REJECTION_RULES: readonly NoiseRule[];
@@ -479,7 +479,28 @@ export function isAutoplayNotAllowedRejectionNoise(input) {
479
479
  if (!PLAY_NOT_ALLOWED_PATTERN.test(message)) {
480
480
  return false;
481
481
  }
482
- const frames = input.frames ?? [];
482
+ return isUnattributablePlayRejection(input.frames);
483
+ }
484
+ // Firefox's `NotSupportedError` for a play() promise whose element has no
485
+ // playable source. Better Stack pattern
486
+ // 1b3195a99afc344886a33243b1b73942b94e74c6bf5ca6b561d3bddc3e3289c6
487
+ // (Kortix Frontend prod, application_id 2346967, KRTX-2998): 1 occurrence, 0
488
+ // identified users, first=last 2026-10-21T09:54:06Z, call site unknown. Every
489
+ // first-party play() promise attaches a `.catch` (`film.tsx`, `sounds.ts`,
490
+ // `video-renderer.tsx`), so the frameless capture is not ours. The guards of
491
+ // `isAutoplayNotAllowedRejectionNoise` keep an attributable one reporting.
492
+ const PLAY_NOT_SUPPORTED_PATTERN = /^The media resource indicated by the src attribute or assigned media provider object was not suitable\.$/;
493
+ /**
494
+ * Whether a Sentry event is the frameless `HTMLMediaElement.play()`
495
+ * `NotSupportedError` rejection. See `PLAY_NOT_SUPPORTED_PATTERN`.
496
+ */
497
+ export function isPlayNotSupportedRejectionNoise(input) {
498
+ if (!PLAY_NOT_SUPPORTED_PATTERN.test(normalizeString(input.message))) {
499
+ return false;
500
+ }
501
+ return isUnattributablePlayRejection(input.frames);
502
+ }
503
+ function isUnattributablePlayRejection(frames = []) {
483
504
  // Negative guard #1: a resolved first-party `apps/web/src/…` frame means
484
505
  // our own code left a play promise unhandled → actionable; keep reporting
485
506
  // so the call site can be found + given its `.catch`.
@@ -501,6 +522,11 @@ export const REJECTION_RULES = [
501
522
  appliesTo: 'sentry',
502
523
  match: isAutoplayNotAllowedRejectionNoise,
503
524
  },
525
+ {
526
+ id: 'play-not-supported',
527
+ appliesTo: 'sentry',
528
+ match: isPlayNotSupportedRejectionNoise,
529
+ },
504
530
  {
505
531
  id: 'non-error-undefined-rejection',
506
532
  appliesTo: 'sentry',
@@ -100,6 +100,10 @@ const CATALOG_PROVIDER_IDS = new Set(CATALOG_PROVIDER_ENV.map((provider) => prov
100
100
  * (a custom provider) keeps initials.
101
101
  */
102
102
  export function providerIconSrc(providerID, baseUrl) {
103
+ // Drawn in code (ProviderLogo), never fetched: models.dev has no Platinum logo
104
+ // and answers with its generic sparkles mark.
105
+ if (providerID === 'platinum')
106
+ return undefined;
103
107
  const icon = PROVIDER_ICON_MAP[providerID]?.icon;
104
108
  if (icon)
105
109
  return baseUrl === undefined ? `https://models.dev/logos/${icon}.svg` : `${baseUrl}/${icon}.svg`;
@@ -144,7 +148,8 @@ export function ProviderLogo({ providerID, name, className, size = 'default', to
144
148
  // An unreachable models.dev logo (offline self-host) falls back to initials.
145
149
  const [failedSrc, setFailedSrc] = useState();
146
150
  const kortix = providerID === 'kortix';
147
- const iconSrc = kortix ? undefined : providerIconSrc(providerID, providerIconBaseUrl);
151
+ const platinum = providerID === 'platinum';
152
+ const iconSrc = kortix || platinum ? undefined : providerIconSrc(providerID, providerIconBaseUrl);
148
153
  const src = iconSrc === failedSrc ? undefined : iconSrc;
149
154
  const sizeClasses = {
150
155
  xs: 'size-4',
@@ -166,10 +171,16 @@ export function ProviderLogo({ providerID, name, className, size = 'default', to
166
171
  // the other sizes read as an avatar, and at 16px a tile around a 12px
167
172
  // mark is all frame and no logo. The initials fallback keeps its tile
168
173
  // at every size: two bare letters at 9px read as debris, not a logo.
169
- (src || kortix) && size === 'xs' ? 'bg-transparent' : light ? LOGO_TILE_LIGHT : 'bg-muted rounded-md', sizeClasses[size], className), "aria-hidden": "true", children: kortix ? (
174
+ (src || kortix || platinum) && size === 'xs' ? 'bg-transparent' : light ? LOGO_TILE_LIGHT : 'bg-muted rounded-md', sizeClasses[size], className), "aria-hidden": "true", children: kortix ? (
170
175
  // The host's brand mark (`useBrand` icon), else the Kortix symbol in currentColor.
171
176
  _jsx(KortixLogo, { variant: "icon", style: {
172
177
  width: `calc(var(--spacing) * ${KORTIX_MARK_STEPS[size]})`,
173
178
  height: `calc(var(--spacing) * ${KORTIX_MARK_STEPS[size]})`,
174
- }, className: light ? LOGO_TILE_LIGHT_TEXT : 'text-foreground' })) : src ? (_jsx(Image, { src: src, alt: "", width: iconSizes[size], height: iconSizes[size], unoptimized: src.startsWith('https:'), onError: () => setFailedSrc(src), className: cn('object-contain', !light && 'dark:invert') })) : (_jsx("span", { className: cn('font-semibold tracking-wide uppercase', light ? LOGO_TILE_LIGHT_TEXT : 'text-muted-foreground', size === 'xs' ? 'text-[9px]' : 'text-xs'), children: initialsFor(providerID, name) })) }));
179
+ }, className: light ? LOGO_TILE_LIGHT_TEXT : 'text-foreground' })) : platinum ? (
180
+ // The Platinum mark (platinum.dev/brand): a flat square in currentColor,
181
+ // the favicon geometry — 14 of 24, inset 5.
182
+ _jsx("svg", { viewBox: "0 0 24 24", style: {
183
+ width: `calc(var(--spacing) * ${KORTIX_MARK_STEPS[size] + 2})`,
184
+ height: `calc(var(--spacing) * ${KORTIX_MARK_STEPS[size] + 2})`,
185
+ }, className: light ? LOGO_TILE_LIGHT_TEXT : 'text-foreground', children: _jsx("rect", { x: "5", y: "5", width: "14", height: "14", fill: "currentColor" }) })) : src ? (_jsx(Image, { src: src, alt: "", width: iconSizes[size], height: iconSizes[size], unoptimized: src.startsWith('https:'), onError: () => setFailedSrc(src), className: cn('object-contain', !light && 'dark:invert') })) : (_jsx("span", { className: cn('font-semibold tracking-wide uppercase', light ? LOGO_TILE_LIGHT_TEXT : 'text-muted-foreground', size === 'xs' ? 'text-[9px]' : 'text-xs'), children: initialsFor(providerID, name) })) }));
175
186
  }
@@ -0,0 +1,42 @@
1
+ /** @internal Kortix apps only. No semver: it may change in any release. */
2
+ import type { UiTranslator } from '../../i18n/index.js';
3
+ import type { ProjectTrigger } from '@kortix/sdk';
4
+ /**
5
+ * The trigger clock's health tones and its "last run" label. The Triggers list
6
+ * (`customize/triggers/schedule`) and the session reminders panel
7
+ * (`session/panel`) both draw them, so they sit below both layers.
8
+ */
9
+ export type ClockState = 'ok' | 'failed' | 'needs_connection' | 'pending' | 'paused' | 'never';
10
+ /**
11
+ * ONE tone per state drives both the reflection and the dot, so they cannot
12
+ * disagree. Three colours, always full strength: green = live (running, ran,
13
+ * or not run yet), red = failed, yellow = paused or waiting on setup.
14
+ */
15
+ export declare const CLOCK_TONE: {
16
+ readonly ok: {
17
+ readonly reflection: "text-kortix-green";
18
+ readonly dot: "bg-kortix-green";
19
+ };
20
+ readonly never: {
21
+ readonly reflection: "text-kortix-green";
22
+ readonly dot: "bg-kortix-green";
23
+ };
24
+ readonly failed: {
25
+ readonly reflection: "text-kortix-red";
26
+ readonly dot: "bg-kortix-red";
27
+ };
28
+ readonly needs_connection: {
29
+ readonly reflection: "text-kortix-yellow";
30
+ readonly dot: "bg-kortix-yellow";
31
+ };
32
+ readonly pending: {
33
+ readonly reflection: "text-kortix-yellow";
34
+ readonly dot: "bg-kortix-yellow";
35
+ };
36
+ readonly paused: {
37
+ readonly reflection: "text-kortix-yellow";
38
+ readonly dot: "bg-kortix-yellow";
39
+ };
40
+ };
41
+ /** "2d ago", "9h ago", "3w ago" in the viewer's locale, or "Never run". */
42
+ export declare function lastRunLabel(trigger: Pick<ProjectTrigger, 'last_fired_at' | 'event'>, t: UiTranslator, locale: string, now?: number): string;
@@ -0,0 +1,34 @@
1
+ const GREEN = { reflection: 'text-kortix-green', dot: 'bg-kortix-green' };
2
+ const RED = { reflection: 'text-kortix-red', dot: 'bg-kortix-red' };
3
+ const YELLOW = { reflection: 'text-kortix-yellow', dot: 'bg-kortix-yellow' };
4
+ /**
5
+ * ONE tone per state drives both the reflection and the dot, so they cannot
6
+ * disagree. Three colours, always full strength: green = live (running, ran,
7
+ * or not run yet), red = failed, yellow = paused or waiting on setup.
8
+ */
9
+ export const CLOCK_TONE = {
10
+ ok: GREEN,
11
+ never: GREEN,
12
+ failed: RED,
13
+ needs_connection: YELLOW,
14
+ pending: YELLOW,
15
+ paused: YELLOW,
16
+ };
17
+ /** "2d ago", "9h ago", "3w ago" in the viewer's locale, or "Never run". */
18
+ export function lastRunLabel(trigger, t, locale, now = Date.now()) {
19
+ const iso = trigger.last_fired_at ?? trigger.event?.last_event_at ?? null;
20
+ const at = iso ? Date.parse(iso) : Number.NaN;
21
+ if (!Number.isFinite(at))
22
+ return t.raw('text3d40a69d3160');
23
+ const rtf = new Intl.RelativeTimeFormat(locale, { style: 'narrow', numeric: 'auto' });
24
+ const min = Math.floor(Math.max(0, now - at) / 60_000);
25
+ if (min < 1)
26
+ return rtf.format(0, 'second');
27
+ if (min < 60)
28
+ return rtf.format(-min, 'minute');
29
+ if (min < 24 * 60)
30
+ return rtf.format(-Math.floor(min / 60), 'hour');
31
+ if (min < 7 * 24 * 60)
32
+ return rtf.format(-Math.floor(min / (24 * 60)), 'day');
33
+ return rtf.format(-Math.floor(min / (7 * 24 * 60)), 'week');
34
+ }
@@ -36,6 +36,12 @@ export interface WebNotificationPayload {
36
36
  preview?: string;
37
37
  /** Localized "Open session" for the finished-turn toast. */
38
38
  openSessionLabel?: string;
39
+ /** The open change request the finished turn produced: the toast offers Review. */
40
+ changeRequest?: {
41
+ crId: string;
42
+ detail: string;
43
+ reviewLabel: string;
44
+ };
39
45
  /** Localized "{count} tasks finished" for the stack several turns collapse into. */
40
46
  stackTitle?: (count: number) => string;
41
47
  /** A row of the notification inbox. Inbox rows exist only for projects with `notification_center` on. */
@@ -31,7 +31,7 @@ import { createElement } from 'react';
31
31
  import { cachedNotificationCenter, cachedNotificationCenterAnswer, } from '../hooks/use-notification-center.js';
32
32
  import { hasWebPushSubscription } from '../notifications/web-push.js';
33
33
  import { customToast, dismissToast, ToastCard, toastIcon, errorToast, infoToast, successToast, warningToast, } from '../../primitives/toast.js';
34
- import { ClockIcon } from '@phosphor-icons/react';
34
+ import { ClockIcon, GitPullRequestIcon } from '@phosphor-icons/react';
35
35
  import { SessionFailedCard, SessionStackCard, } from '../notifications/session-toast-cards.js';
36
36
  import { logger } from './logger.js';
37
37
  import { softNavigate } from './navigation/router-bridge.js';
@@ -42,7 +42,7 @@ import { playSound } from './sounds.js';
42
42
  import { openTabAndNavigate, useTabStore } from '../stores/tab-store.js';
43
43
  import { useTurnAttentionStore } from '../stores/turn-attention-store.js';
44
44
  import { useWebNotificationStore } from '../stores/web-notification-store.js';
45
- import { normalizeAppPathname } from '@kortix/sdk';
45
+ import { listChangeRequests, normalizeAppPathname, } from '@kortix/sdk';
46
46
  // ============================================================================
47
47
  // Server push preference
48
48
  // ============================================================================
@@ -138,7 +138,7 @@ function navigateToSession(sessionId, sessionTitle, opts) {
138
138
  const projectId = opts?.projectId ?? currentProjectId();
139
139
  if (!projectId)
140
140
  return;
141
- const href = projectSessionHref(projectId, sessionId);
141
+ const href = projectSessionHref(projectId, sessionId) + (opts?.search ?? '');
142
142
  try {
143
143
  // Open/activate the tab in the tab store + pushState
144
144
  openTabAndNavigate({
@@ -497,6 +497,15 @@ function showSessionToast(payload, sessionId) {
497
497
  // focus) names nothing and opens nothing: the person is already there.
498
498
  // Read at render, so it follows where the person is now.
499
499
  const elsewhere = () => !isViewingSession(sessionId);
500
+ const cr = payload.changeRequest;
501
+ // `?cr=` opens the change request's review dialog on the session page.
502
+ const review = () => {
503
+ navigateToSession(sessionId, payload.body, {
504
+ projectId: payload.projectId,
505
+ search: cr ? `?cr=${encodeURIComponent(cr.crId)}` : undefined,
506
+ });
507
+ payload.onClick?.();
508
+ };
500
509
  if (item.failed) {
501
510
  customToast((dismiss) => createElement(SessionFailedCard, {
502
511
  title: payload.inAppTitle ?? payload.title,
@@ -512,12 +521,23 @@ function showSessionToast(payload, sessionId) {
512
521
  message: payload.inAppTitle ?? payload.title,
513
522
  icon: toastIcon('success'),
514
523
  variant: 'success',
524
+ // On the session itself the change request card is in the transcript,
525
+ // so the toast only says it is there.
515
526
  options: elsewhere()
516
527
  ? {
517
- entity: { label: item.label },
518
- actions: [{ label: payload.openSessionLabel ?? openLabel, onClick: () => open(item) }],
528
+ entity: {
529
+ icon: cr ? createElement(GitPullRequestIcon, { className: 'size-3.5' }) : undefined,
530
+ label: item.label,
531
+ },
532
+ description: cr?.detail,
533
+ actions: [
534
+ ...(cr ? [{ label: cr.reviewLabel, onClick: review }] : []),
535
+ { label: payload.openSessionLabel ?? openLabel, onClick: () => open(item) },
536
+ ],
519
537
  }
520
- : undefined,
538
+ : cr
539
+ ? { description: cr.detail }
540
+ : undefined,
521
541
  onDismiss: dismiss,
522
542
  }), { id, duration: NOTIFICATION_VISIBLE_MS, onClose });
523
543
  }
@@ -576,7 +596,7 @@ export function notifyTaskCompleteFor({ sessionId, sessionTitle, projectId, noti
576
596
  noteTurnCompleteShown(sessionId);
577
597
  const name = sessionDisplayName(sessionTitle, sessionId);
578
598
  const label = sessionTitle ? `"${name.slice(0, 60)}"` : name;
579
- sendWebNotification({
599
+ const payload = {
580
600
  type: 'completion',
581
601
  entityLabel: name,
582
602
  openSessionLabel: tI18nComplete.raw('textb205bb47f81a'),
@@ -591,8 +611,38 @@ export function notifyTaskCompleteFor({ sessionId, sessionTitle, projectId, noti
591
611
  // deep link (the same rule the click-time path documents above).
592
612
  projectId,
593
613
  notificationCenter,
614
+ };
615
+ if (!projectId) {
616
+ sendWebNotification(payload);
617
+ return;
618
+ }
619
+ void openChangeRequestOf(projectId, sessionId).then((cr) => {
620
+ if (!cr) {
621
+ sendWebNotification(payload);
622
+ return;
623
+ }
624
+ const detail = tI18nComplete('text57f57edb6f16', { number: cr.number });
625
+ sendWebNotification({
626
+ ...payload,
627
+ body: `${payload.body} ${detail}`,
628
+ changeRequest: { crId: cr.cr_id, detail, reviewLabel: tI18nComplete.raw('textaff0766a5290') },
629
+ });
594
630
  });
595
631
  }
632
+ /** How long the finished-turn toast waits for the change request lookup. */
633
+ const CHANGE_REQUEST_LOOKUP_MS = 2_500;
634
+ /**
635
+ * The newest open change request the session opened, or null. Server-vouched
636
+ * by `origin_session_id`, the same rule as the transcript's change request card.
637
+ * A slow or failed lookup answers null, so the toast never waits on it for long.
638
+ */
639
+ function openChangeRequestOf(projectId, sessionId) {
640
+ const lookup = listChangeRequests(projectId, 'open', { originSessionId: sessionId, limit: 1 })
641
+ .then((page) => page.change_requests[0] ?? null)
642
+ .catch(() => null);
643
+ const timeout = new Promise((resolve) => setTimeout(() => resolve(null), CHANGE_REQUEST_LOOKUP_MS));
644
+ return Promise.race([lookup, timeout]);
645
+ }
596
646
  /**
597
647
  * Notify that a session encountered an error.
598
648
  */
@@ -14,6 +14,19 @@ export interface NotificationPanelProps {
14
14
  }
15
15
  /** Props only: the header, the All / Needs you / Done tabs, the rows, and the footer offer. */
16
16
  export declare function NotificationPanel({ rows, state, unreadCount, prompt, onOpenRow, onMarkAllRead, onRetry, onTurnOn, onDismissPrompt, }: NotificationPanelProps): import("react").JSX.Element;
17
+ /**
18
+ * The bell (Paper B12 in the toast's badge-glow style): the glyph muted, then
19
+ * again in the badge's colour under `BELL_REFLECTION_MASK`, so the
20
+ * colour reaches in from the corner the count sits on, as `EntityTile` in
21
+ * `components/ui/toast.tsx` and `TriggerTile` draw it. Yellow when something
22
+ * waits on the person (the Needs you tab), blue otherwise.
23
+ */
24
+ /**
25
+ * The toast's `REFLECTION_MASK` scaled from its 20px icon to the 16px bell
26
+ * (16px → 12px), centred on the glyph's bottom-right corner where the count
27
+ * sits. At full size it would tint the whole bell.
28
+ */
29
+ export declare const BELL_REFLECTION_MASK = "radial-gradient(circle 12px at 100% 100%, #000 35%, transparent 100%)";
17
30
  export declare function BellMark({ count, needsYou, surface, }: {
18
31
  count: number;
19
32
  needsYou: boolean;
@@ -78,7 +78,7 @@ export function NotificationPanel({ rows, state, unreadCount, prompt, onOpenRow,
78
78
  * (16px → 12px), centred on the glyph's bottom-right corner where the count
79
79
  * sits. At full size it would tint the whole bell.
80
80
  */
81
- const BELL_REFLECTION_MASK = 'radial-gradient(circle 12px at 100% 100%, #000 35%, transparent 100%)';
81
+ export const BELL_REFLECTION_MASK = 'radial-gradient(circle 12px at 100% 100%, #000 35%, transparent 100%)';
82
82
  export function BellMark({ count, needsYou, surface = 'bg-background', }) {
83
83
  const tone = needsYou
84
84
  ? { reflection: 'text-kortix-yellow', disc: 'bg-kortix-yellow' }
@@ -0,0 +1,12 @@
1
+ /** @internal Kortix apps only. No semver: it may change in any release. */
2
+ import type { SessionReminder } from '@kortix/sdk';
3
+ export type ReminderStatusFilter = 'all' | 'active' | 'paused' | 'failed';
4
+ /** By next run: within the hour, the day, the week, or later than a week. */
5
+ export type ReminderTimeFilter = 'any' | 'hour' | 'day' | 'week' | 'later';
6
+ export declare const REMINDER_STATUS_FILTERS: readonly ReminderStatusFilter[];
7
+ export declare const REMINDER_TIME_FILTERS: readonly ReminderTimeFilter[];
8
+ export declare function matchesStatus(reminder: SessionReminder, status: ReminderStatusFilter): boolean;
9
+ /** A time filter other than `any` keeps only active reminders: a paused one has no next run. */
10
+ export declare function matchesTime(reminder: SessionReminder, time: ReminderTimeFilter, now: number): boolean;
11
+ /** Every word of the query appears in the title, the prompt or the schedule label. */
12
+ export declare function matchesQuery(reminder: SessionReminder, query: string, schedule: string): boolean;
@@ -0,0 +1,34 @@
1
+ export const REMINDER_STATUS_FILTERS = ['all', 'active', 'paused', 'failed'];
2
+ export const REMINDER_TIME_FILTERS = ['any', 'hour', 'day', 'week', 'later'];
3
+ const HOUR = 60 * 60 * 1000;
4
+ const WINDOW_MS = {
5
+ hour: HOUR,
6
+ day: 24 * HOUR,
7
+ week: 7 * 24 * HOUR,
8
+ };
9
+ export function matchesStatus(reminder, status) {
10
+ if (status === 'all')
11
+ return true;
12
+ if (status === 'failed')
13
+ return reminder.last_status === 'failed';
14
+ return status === 'active' ? reminder.state === 'active' : reminder.state !== 'active';
15
+ }
16
+ /** A time filter other than `any` keeps only active reminders: a paused one has no next run. */
17
+ export function matchesTime(reminder, time, now) {
18
+ if (time === 'any')
19
+ return true;
20
+ const next = reminder.state === 'active' && reminder.next_fire_at ? Date.parse(reminder.next_fire_at) : NaN;
21
+ if (!Number.isFinite(next))
22
+ return false;
23
+ if (time === 'later')
24
+ return next - now > WINDOW_MS.week;
25
+ return next - now <= WINDOW_MS[time];
26
+ }
27
+ /** Every word of the query appears in the title, the prompt or the schedule label. */
28
+ export function matchesQuery(reminder, query, schedule) {
29
+ const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
30
+ if (words.length === 0)
31
+ return true;
32
+ const haystack = [reminder.name, reminder.prompt, schedule].filter(Boolean).join(' ').toLowerCase();
33
+ return words.every((word) => haystack.includes(word));
34
+ }
@@ -14,6 +14,12 @@ export declare function formatFireTime(iso: string, locale: string, now: number)
14
14
  */
15
15
  export declare function nearestFire(reminders: readonly Pick<SessionReminder, 'state' | 'next_fire_at' | 'last_fired_at' | 'at'>[]): number | null;
16
16
  export declare function soonestFire(reminders: readonly Pick<SessionReminder, 'state' | 'next_fire_at'>[]): number | null;
17
+ /**
18
+ * How much of the wait before the next fire has passed, 0..1: the interval of
19
+ * an `every` reminder, else the span since the last fire (or creation). Null
20
+ * when there is no next fire to count toward.
21
+ */
22
+ export declare function elapsedShare(reminder: Pick<SessionReminder, 'next_fire_at' | 'every_seconds' | 'last_fired_at' | 'created_at'>, now: number): number | null;
17
23
  /** The first line of the reminder text, for a one-line row title. */
18
24
  export declare function reminderTitle(reminder: Pick<SessionReminder, 'name' | 'prompt'>): string;
19
25
  /** "Cron 0 9 * * * Europe/Berlin", "Every 1h" or "Once"; `t` is the `reminders` translator. */
@@ -10,10 +10,12 @@ export function formatFireTime(iso, locale, now) {
10
10
  const deltaMin = Math.round((at - now) / 60_000);
11
11
  const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: 'short' });
12
12
  const abs = Math.abs(deltaMin);
13
+ // `short` abbreviates with a period ("in 32 min."); a row's trailing label reads cleaner without it.
14
+ const relative = (value, unit) => rtf.format(value, unit).replace(/\.(?=\s|$)/g, '');
13
15
  if (abs < 60)
14
- return rtf.format(deltaMin, 'minute');
16
+ return relative(deltaMin, 'minute');
15
17
  if (abs < 24 * 60)
16
- return rtf.format(Math.round(deltaMin / 60), 'hour');
18
+ return relative(Math.round(deltaMin / 60), 'hour');
17
19
  const date = new Date(at);
18
20
  if (deltaMin > 0 && deltaMin < 7 * 24 * 60) {
19
21
  return date.toLocaleString(locale, { weekday: 'short', hour: '2-digit', minute: '2-digit' });
@@ -60,6 +62,22 @@ export function soonestFire(reminders) {
60
62
  }
61
63
  return soonest;
62
64
  }
65
+ /**
66
+ * How much of the wait before the next fire has passed, 0..1: the interval of
67
+ * an `every` reminder, else the span since the last fire (or creation). Null
68
+ * when there is no next fire to count toward.
69
+ */
70
+ export function elapsedShare(reminder, now) {
71
+ const next = reminder.next_fire_at ? Date.parse(reminder.next_fire_at) : NaN;
72
+ if (!Number.isFinite(next))
73
+ return null;
74
+ const start = reminder.every_seconds
75
+ ? next - reminder.every_seconds * 1000
76
+ : Date.parse(reminder.last_fired_at ?? reminder.created_at ?? '');
77
+ if (!Number.isFinite(start) || next <= start)
78
+ return null;
79
+ return Math.min(1, Math.max(0, (now - start) / (next - start)));
80
+ }
63
81
  /** The first line of the reminder text, for a one-line row title. */
64
82
  export function reminderTitle(reminder) {
65
83
  return reminder.name ?? reminder.prompt.split('\n')[0].trim();
@@ -12,7 +12,7 @@ export interface ReadyChipState {
12
12
  primaryName?: string;
13
13
  }
14
14
  /** The panel surfaces reachable from outside the panel. */
15
- export type QuickView = 'terminal' | 'audit' | 'browser' | 'files';
15
+ export type QuickView = 'terminal' | 'audit' | 'browser' | 'files' | 'reminders';
16
16
  /**
17
17
  * Extra aim for a quick-view request — "open the browser" vs "open the browser
18
18
  * ON THIS URL".
@@ -11,6 +11,10 @@
11
11
  * the active row is what is left, and it is also the cheaper shape: exactly one
12
12
  * card is mounted at any time, by construction rather than by timing luck.
13
13
  *
14
+ * The store also holds the active row's element and the one card's content
15
+ * slot (`SessionBriefCardHost`): rows render their brief into that slot, and
16
+ * the host glides to the row instead of a new card appearing at it.
17
+ *
14
18
  * The delays live here rather than on the primitive for a performance reason.
15
19
  * If `openDelay` were a prop derived from `warm`, every row in the sidebar would
16
20
  * re-render each time the group warmed or cooled. Nothing selects `warm`, so it
@@ -20,15 +24,19 @@
20
24
  /** The first card costs a delay, so a pointer crossing the list on its way
21
25
  * somewhere else does not flash open every row it passes over. */
22
26
  export declare const HOVER_OPEN_DELAY_MS = 200;
27
+ /** While a card is open, a move to another row waits this long, and a newer
28
+ * row restarts the wait: a pointer sweeping down the list moves the card once,
29
+ * to where it stops, instead of re-rendering it for every row it crosses. */
30
+ export declare const HOVER_MOVE_DEBOUNCE_MS = 50;
23
31
  /** Long enough for the pointer to cross the `sideOffset` gap into the card. */
24
32
  export declare const HOVER_CLOSE_DELAY_MS = 100;
25
33
  /** How long the group stays warm after its last card closes. */
26
34
  export declare const HOVER_WARM_GRACE_MS = 300;
27
35
  /**
28
- * Once one card is open the group is warm and the next row is instant, so the
29
- * card reads as moving to the row under the pointer instead of closing and
30
- * reopening. This is the rule the design system already states for tooltips:
31
- * delay before the first, none for the rest while the group is active.
36
+ * Once one card is open the group is warm and the next row costs only the move
37
+ * debounce, so the card reads as moving to the row under the pointer instead of
38
+ * closing and reopening. This is the rule the design system already states for
39
+ * tooltips: delay before the first, (almost) none for the rest.
32
40
  */
33
41
  export declare function hoverOpenDelayMs(warm: boolean): number;
34
42
  /**
@@ -38,9 +46,16 @@ export declare function hoverOpenDelayMs(warm: boolean): number;
38
46
  export declare function clearIfActive(current: string | null, sessionId: string): string | null;
39
47
  interface SessionHoverState {
40
48
  activeSessionId: string | null;
49
+ /** The active row's element: the card lines up with it. */
50
+ anchor: HTMLElement | null;
51
+ /** Keyboard focus moves the card without a glide. */
52
+ via: 'pointer' | 'focus';
53
+ /** The shared card's content slot, from `SessionBriefCardHost`. */
54
+ slot: HTMLElement | null;
55
+ setSlot: (slot: HTMLElement | null) => void;
41
56
  /** Deliberately unselected by any component — see the note above. */
42
57
  warm: boolean;
43
- openSession: (sessionId: string) => void;
58
+ openSession: (sessionId: string, anchor?: HTMLElement, via?: 'pointer' | 'focus') => void;
44
59
  closeSession: (sessionId: string) => void;
45
60
  /** Immediate, for a click that opens something else and for unmount. */
46
61
  dismiss: () => void;