@kortix/ui 0.14.5 → 0.14.6

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 (124) hide show
  1. package/dist/access/access/access-dialog.js +8 -5
  2. package/dist/access/access/copy-row.js +2 -2
  3. package/dist/access/sharing-picker.js +2 -1
  4. package/dist/browser/app-in-browser.js +5 -3
  5. package/dist/browser/browser-panel.js +6 -6
  6. package/dist/browser/sandbox-browser-chrome.d.ts +4 -1
  7. package/dist/browser/sandbox-browser-chrome.js +4 -3
  8. package/dist/customize/agents/agent-access.d.ts +20 -0
  9. package/dist/customize/agents/agent-access.js +17 -0
  10. package/dist/customize/agents/agent-grant-pages.js +5 -6
  11. package/dist/customize/agents/agent-page.js +2 -1
  12. package/dist/customize/agents/agents-page.js +18 -1
  13. package/dist/customize/connectors/add-mcp/add-mcp-server.js +4 -2
  14. package/dist/customize/connectors/connector-routes.d.ts +3 -1
  15. package/dist/customize/connectors/connector-routes.js +18 -0
  16. package/dist/customize/connectors/connectors-page.js +23 -4
  17. package/dist/customize/connectors/detail/connector-advanced-tools.js +4 -6
  18. package/dist/customize/providers/provider-accordion.js +2 -1
  19. package/dist/customize/providers/provider-connect.js +2 -1
  20. package/dist/customize/sections/component/telegram-channel-row.js +1 -1
  21. package/dist/customize/sections/connector-connection-modal.js +11 -9
  22. package/dist/customize/sections/connectors-view.js +11 -13
  23. package/dist/customize/sections/view/gateway/gateway-routing.js +15 -18
  24. package/dist/customize/settings/rail-copy-context.js +5 -3
  25. package/dist/customize/settings/tabs/sandbox-template-form.js +9 -12
  26. package/dist/customize/shared/capability-tabs.js +2 -1
  27. package/dist/customize/skills/skills-page.js +3 -5
  28. package/dist/customize/triggers/schedule/event-trigger-copy.js +3 -2
  29. package/dist/customize/triggers/schedule/schedule-builder.js +7 -4
  30. package/dist/customize/triggers/schedule/trigger-composer.js +5 -2
  31. package/dist/customize/triggers/schedule/when-event.js +1 -1
  32. package/dist/diff/diff-view.js +9 -10
  33. package/dist/files/editor/code-editor.js +2 -3
  34. package/dist/files/project/components/change-request-detail-dialog.d.ts +3 -1
  35. package/dist/files/project/components/change-request-detail-dialog.js +3 -5
  36. package/dist/files/project/components/change-request-status-badge.d.ts +12 -0
  37. package/dist/files/project/components/change-request-status-badge.js +27 -0
  38. package/dist/files/project/components/change-requests-panel.js +1 -1
  39. package/dist/files/project/components/drive-explorer.js +2 -1
  40. package/dist/files/project/components/drive-grid-view.js +11 -7
  41. package/dist/files/project/components/drive-list-view.js +4 -4
  42. package/dist/files/project/components/file-breadcrumbs.js +1 -1
  43. package/dist/files/project/components/file-explorer-page.js +1 -1
  44. package/dist/files/project/components/file-history-popover.js +1 -6
  45. package/dist/files/project/components/file-search.js +6 -1
  46. package/dist/files/project/components/open-change-request-dialog.js +2 -1
  47. package/dist/files/project/components/version-selector-logic.d.ts +11 -0
  48. package/dist/files/project/components/version-selector-logic.js +26 -0
  49. package/dist/files/project/components/version-selector.js +11 -20
  50. package/dist/files/project/hooks/use-server-health.js +17 -15
  51. package/dist/files/renderers/docx/docx-viewer.js +0 -5
  52. package/dist/files/renderers/image-renderer.js +6 -24
  53. package/dist/files/renderers/pdf/pdf-viewer.js +1 -1
  54. package/dist/files/renderers/presentation/FullScreenPresentationViewer.js +2 -11
  55. package/dist/files/renderers/show-content-renderer.js +1 -1
  56. package/dist/files/renderers/sqlite-renderer.js +8 -3
  57. package/dist/files/renderers/xlsx/xlsx-viewer.js +15 -16
  58. package/dist/files/tree/components/file-tree-item.js +1 -1
  59. package/dist/files/viewer/file-preview-modal.js +1 -1
  60. package/dist/files/viewer/use-file-content-state.js +1 -1
  61. package/dist/i18n/en.d.ts +10 -0
  62. package/dist/i18n/en.js +10 -0
  63. package/dist/internal/desktop/desktop-back-button.js +5 -3
  64. package/dist/internal/hooks/use-auto-scroll.js +5 -3
  65. package/dist/internal/notifications/notification-rows.js +2 -1
  66. package/dist/internal/outcomes/outcome-types.d.ts +5 -0
  67. package/dist/markdown/code/inline-code.js +8 -0
  68. package/dist/markdown/code/output-paths-context.d.ts +18 -0
  69. package/dist/markdown/code/output-paths-context.js +35 -0
  70. package/dist/markdown/genui/components/charts.js +6 -5
  71. package/dist/primitives/map.js +2 -1
  72. package/dist/primitives/preview-image.js +4 -2
  73. package/dist/primitives/textarea.js +1 -1
  74. package/dist/primitives/typed-title.js +12 -14
  75. package/dist/primitives/typing-animation.js +4 -2
  76. package/dist/session/composer/agent-selector.js +6 -1
  77. package/dist/session/composer/composer-chat-input.js +5 -3
  78. package/dist/session/composer/composer-logic.js +8 -7
  79. package/dist/session/composer/draft/composer-draft.js +4 -4
  80. package/dist/session/composer/pasted-text.js +3 -2
  81. package/dist/session/composer/stores/queued-draft-store.js +3 -2
  82. package/dist/session/header/reminder-parts.d.ts +11 -2
  83. package/dist/session/header/reminder-parts.js +9 -4
  84. package/dist/session/media/sandbox-url-detector.js +0 -7
  85. package/dist/session/outcomes/change-request-outcomes.js +24 -3
  86. package/dist/session/overrides/provider-secret-pool-editor.js +2 -1
  87. package/dist/session/panel/advanced/advanced-panel.js +3 -7
  88. package/dist/session/panel/easy/app-preview.js +12 -2
  89. package/dist/session/panel/easy/easy-panel-logic.d.ts +1 -1
  90. package/dist/session/panel/easy/easy-panel-logic.js +1 -1
  91. package/dist/session/panel/easy/outputs-card.js +2 -1
  92. package/dist/session/panel/easy/panel-card.js +5 -3
  93. package/dist/session/panel/easy/preview-load.d.ts +14 -0
  94. package/dist/session/panel/easy/preview-load.js +15 -0
  95. package/dist/session/panel/easy/step-detail-body.js +2 -7
  96. package/dist/session/panel/session-panel-provider.js +6 -2
  97. package/dist/session/panel/shared/use-panel-derivations.js +6 -4
  98. package/dist/session/panel/use-session-panel-layout.js +7 -5
  99. package/dist/session/print/session-print-header.js +9 -8
  100. package/dist/session/thread/session-chat.js +12 -9
  101. package/dist/session/thread/session-history.js +3 -1
  102. package/dist/session/thread/transcript.js +13 -7
  103. package/dist/session/tool/shared/file-list.js +19 -2
  104. package/dist/session/tool/shared/infrastructure-contexts.d.ts +7 -0
  105. package/dist/session/tool/shared/infrastructure-contexts.js +7 -0
  106. package/dist/session/tool/shared/infrastructure-output.js +9 -5
  107. package/dist/session/tool/shared/infrastructure-row-header.js +9 -1
  108. package/dist/session/tool/shared/infrastructure-rows.js +7 -5
  109. package/dist/session/tool/tools/bash-tool.js +10 -6
  110. package/dist/session/tool/tools/web-search-tool.js +25 -3
  111. package/dist/session/turn/recovered-failures.d.ts +8 -0
  112. package/dist/session/turn/recovered-failures.js +32 -0
  113. package/dist/session/turn/streaming-cadence.js +5 -8
  114. package/dist/styles.css +1 -1
  115. package/dist/terminal/session-terminal-panel.js +3 -5
  116. package/dist/theme/theme.js +2 -1
  117. package/dist/workspace/projects/modal/github-import-pickers.js +7 -6
  118. package/dist/workspace/review/review-center.js +13 -10
  119. package/dist/workspace/session-workspace.js +5 -9
  120. package/dist/workspace/sessions/deleted-session-row.js +5 -2
  121. package/dist/workspace/sidebar/footer/project-computer-nav.js +6 -4
  122. package/dist/workspace/sidebar/footer/project-mcp-nav.js +6 -2
  123. package/dist/workspace/sidebar/modal/rename-session-modal.js +8 -3
  124. package/package.json +3 -3
@@ -15,7 +15,7 @@ import { elapsedShare, formatFireTime, reminderTitle, scheduleLabel } from '../.
15
15
  import { cn } from '../../internal/lib/utils.js';
16
16
  import { useLocale, useTranslations } from '../../i18n/index.js';
17
17
  import { useSessionReminders } from '@kortix/sdk/react';
18
- import { ArrowUpRightIcon, PauseIcon, PlayIcon } from '@phosphor-icons/react';
18
+ import { ArrowClockwiseIcon, ArrowUpRightIcon, PauseIcon, PlayIcon } from '@phosphor-icons/react';
19
19
  import { useState } from 'react';
20
20
  export const isFailedReminder = (reminder) => reminder.last_status === 'failed';
21
21
  /** Soonest next fire first; paused ones, which have no next fire, last. */
@@ -70,16 +70,16 @@ export function ReminderFacts({ reminder, now, className, }) {
70
70
  const failed = isFailedReminder(reminder);
71
71
  return (_jsxs("dl", { className: cn('space-y-1.5 text-xs', className), children: [_jsxs("div", { className: "flex justify-between gap-3", children: [_jsx("dt", { className: "text-muted-foreground", children: t('repeats') }), _jsx("dd", { className: "text-foreground truncate", children: scheduleLabel(reminder, (key, values) => t(key, values)) })] }), _jsxs("div", { className: "flex justify-between gap-3", children: [_jsx("dt", { className: "text-muted-foreground", children: t('timeZone') }), _jsx("dd", { className: "text-foreground truncate", children: reminder.timezone })] }), _jsxs("div", { className: "flex justify-between gap-3", children: [_jsx("dt", { className: "text-muted-foreground", children: t('lastRun') }), _jsx("dd", { className: "text-foreground flex items-center gap-1.5", children: reminder.last_fired_at ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, className: cn('size-1.5 rounded-full', failed ? 'bg-kortix-red' : 'bg-kortix-green') }), formatFireTime(reminder.last_fired_at, locale, now)] })) : (t('neverRun')) })] }), failed && reminder.last_error ? (_jsx("p", { className: "text-foreground bg-kortix-red/15 mt-2 rounded-md px-2 py-1.5 font-mono break-words", children: reminder.last_error })) : null] }));
72
72
  }
73
- /** Pause or resume, remove, and the link into the Reminders tab. */
73
+ /** Retry a failed run, pause or resume, remove, and the link into the Reminders tab. */
74
74
  export function ReminderActions({ reminder, controls, manageHref, onNavigate, className, }) {
75
75
  const t = useTranslations('reminders');
76
76
  const paused = reminder.state !== 'active';
77
- return (_jsxs("div", { className: cn('flex items-center gap-1', className), children: [_jsxs(Button, { variant: "secondary", size: "xs", disabled: controls.pending, onClick: () => controls.setEnabled(reminder, paused), children: [paused ? _jsx(PlayIcon, { className: "size-3.5" }) : _jsx(PauseIcon, { className: "size-3.5" }), paused ? t('resume') : t('pause')] }), _jsx(Button, { variant: "ghost", size: "xs", className: "text-destructive", onClick: () => controls.requestRemove(reminder), children: t('remove') }), manageHref ? (
77
+ return (_jsxs("div", { className: cn('flex items-center gap-1', className), children: [isFailedReminder(reminder) ? (_jsxs(Button, { variant: "secondary", size: "xs", disabled: controls.running, onClick: () => controls.run(reminder), children: [_jsx(ArrowClockwiseIcon, { className: "size-3.5" }), t('retry')] })) : null, _jsxs(Button, { variant: "secondary", size: "xs", disabled: controls.pending, onClick: () => controls.setEnabled(reminder, paused), children: [paused ? _jsx(PlayIcon, { className: "size-3.5" }) : _jsx(PauseIcon, { className: "size-3.5" }), paused ? t('resume') : t('pause')] }), _jsx(Button, { variant: "ghost", size: "xs", className: "text-destructive", onClick: () => controls.requestRemove(reminder), children: t('remove') }), manageHref ? (
78
78
  // An icon, not a label: three text buttons overflow the 288px card.
79
79
  _jsx(Hint, { label: t('openInReminders'), side: "top", sideOffset: 6, alignOffset: 0, children: _jsx(Button, { asChild: true, variant: "ghost", size: "icon-xs", className: "text-muted-foreground ml-auto", "aria-label": t('openInReminders'), children: _jsx(HoverPrefetchLink, { href: manageHref, onClick: onNavigate, children: _jsx(ArrowUpRightIcon, { className: "size-3.5" }) }) }) })) : null] }));
80
80
  }
81
81
  /**
82
- * The session's reminders with pause, resume and remove. Remove asks first:
82
+ * The session's reminders with run, pause, resume and remove. Remove asks first:
83
83
  * render `confirm` once next to whatever calls `requestRemove`.
84
84
  */
85
85
  export function useReminderControls(projectId, sessionId) {
@@ -88,6 +88,11 @@ export function useReminderControls(projectId, sessionId) {
88
88
  const [removing, setRemoving] = useState(null);
89
89
  const controls = {
90
90
  pending: reminders.update.isPending,
91
+ running: reminders.run.isPending,
92
+ run: (reminder) => reminders.run.mutate(reminder.id, {
93
+ onSuccess: () => successToast(t('runQueued')),
94
+ onError: (error) => errorToast(error instanceof Error ? error.message : t('runFailed')),
95
+ }),
91
96
  setEnabled: (reminder, enabled) => reminders.update.mutate({ reminderId: reminder.id, enabled }, {
92
97
  onSuccess: () => successToast(enabled ? t('resumed') : t('paused')),
93
98
  onError: (error) => errorToast(error instanceof Error ? error.message : t('updateFailed')),
@@ -102,7 +102,6 @@ function InlineIframePreview({ proxyUrl, port }) {
102
102
  }
103
103
  function SandboxUrlChip({ detected, proxyUrl, }) {
104
104
  const tHardcodedUi = useTranslations('hardcodedUi');
105
- const [copied, setCopied] = useState(false);
106
105
  const tabId = `preview:${detected.port}`;
107
106
  const tabHref = `/p/${detected.port}`;
108
107
  const internalUrl = toInternalUrl(detected.port, detected.path);
@@ -120,12 +119,6 @@ function SandboxUrlChip({ detected, proxyUrl, }) {
120
119
  }),
121
120
  }, undefined, shallowNavigate);
122
121
  }, [tabId, tHardcodedUi, detected.port, tabHref, proxyUrl, internalUrl, shallowNavigate]);
123
- const handleCopyUrl = useCallback(() => {
124
- navigator.clipboard.writeText(proxyUrl).then(() => {
125
- setCopied(true);
126
- setTimeout(() => setCopied(false), 2000);
127
- });
128
- }, [proxyUrl]);
129
122
  const openPreview = useOpenPreviewLink();
130
123
  const handleOpenExternal = useCallback(() => {
131
124
  if (openPreview?.(null, detected.originalUrl, proxyUrl))
@@ -51,6 +51,27 @@ export function changeRequestOutcomes(crs, sessionId, tI18nComplete) {
51
51
  // a server that sends `null` should degrade to the fallback sentence
52
52
  // rather than throw inside a transcript render.
53
53
  const summary = cr.description?.trim();
54
+ // An agent revises its work by closing this request and opening the next
55
+ // on the same branch. The card says so and opens the replacement
56
+ // (KRTX-3031). The card itself stays this request's: id and reference.
57
+ const replacement = cr.status === 'closed' ? (cr.superseded_by ?? null) : null;
58
+ const status = replacement
59
+ ? {
60
+ label: tI18nComplete
61
+ ? tI18nComplete('textddcd87c21894', { number: replacement.number })
62
+ : `Superseded by #${replacement.number}`,
63
+ tone: word.tone,
64
+ }
65
+ : { label: word.label, tone: word.tone };
66
+ const action = replacement
67
+ ? {
68
+ label: tI18nComplete
69
+ ? tI18nComplete('texta8ef31fa7d25', { number: replacement.number })
70
+ : `View #${replacement.number}`,
71
+ intent: 'open',
72
+ target: replacement.cr_id,
73
+ }
74
+ : { label: word.action, intent: 'open' };
54
75
  return {
55
76
  id: `cr:${cr.cr_id}`,
56
77
  kind: 'change_request',
@@ -62,7 +83,7 @@ export function changeRequestOutcomes(crs, sessionId, tI18nComplete) {
62
83
  // Kept for the modal, which has room for it. The card no longer renders
63
84
  // a description; see `outcome-card.tsx`.
64
85
  description: summary || tI18nComplete?.raw('text5c58b6d31ba9') || 'Ready for you to look over.',
65
- status: { label: word.label, tone: word.tone },
86
+ status,
66
87
  at: epoch(cr.created_at),
67
88
  // The number is a REFERENCE, not a name — it belongs in the quiet line
68
89
  // with the status. `into <base_ref>` is gone: a branch name is the one
@@ -73,8 +94,8 @@ export function changeRequestOutcomes(crs, sessionId, tI18nComplete) {
73
94
  ? tI18nComplete('texta4fc39798651', { number: cr.number })
74
95
  : `Change request #${cr.number}`,
75
96
  ],
76
- action: { label: word.action, intent: 'open' },
77
- resourceHref: `?cr=${cr.cr_id}`,
97
+ action,
98
+ resourceHref: `?cr=${replacement?.cr_id ?? cr.cr_id}`,
78
99
  };
79
100
  });
80
101
  }
@@ -57,9 +57,10 @@ function PoolChoices({ projectId, providers, providerId, onProviderChange, keys,
57
57
  const personalChatGpt = providerId === 'codex' && personalKeys;
58
58
  const available = new Set(keys.map((secret) => secret.secret_id));
59
59
  const unavailable = selected.filter((secretId) => !available.has(secretId));
60
+ const selectedIds = new Set(selected);
60
61
  return _jsxs(_Fragment, { children: [_jsxs(Field, { className: "gap-1.5", children: [_jsx(FieldLabel, { htmlFor: id, children: t('provider') }), _jsxs(Select, { value: providerId, onValueChange: onProviderChange, disabled: disabled, children: [_jsx(SelectTrigger, { id: id, children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: providers.map((provider) => _jsx(SelectItem, { value: provider, children: provider === 'codex' ? 'ChatGPT' : LLM_PROVIDER_BY_ID.get(provider)?.label ?? provider }, provider)) })] })] }), _jsx("p", { className: "text-muted-foreground text-xs", "aria-live": "polite", children: configured
61
62
  ? selected.length ? t(selected.length === 1 ? 'selectedOneForSession' : 'selectedForSession', { count: selected.length }) : t('providerDisabled')
62
- : t(personalChatGpt ? 'personalChatGptDefault' : 'projectDefault') }), unavailable.length > 0 && _jsxs("div", { className: "space-y-1", role: "status", children: [_jsx("p", { className: "text-muted-foreground text-xs", children: t('unavailableKeys', { count: unavailable.length }) }), _jsx(Button, { size: "sm", variant: "outline-ghost", disabled: disabled || readOnly, onClick: () => onChange(selected.filter((secretId) => available.has(secretId))), children: t('removeUnavailable') })] }), _jsx("div", { className: "max-h-44 space-y-1 overflow-y-auto", children: keys.map((secret) => _jsxs("label", { className: "hover:bg-hover has-[:focus-visible]:ring-ring flex min-h-10 items-center gap-2 rounded-md px-2 py-2 text-sm has-[:focus-visible]:ring-2 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50", children: [_jsx(Checkbox, { checked: selected.includes(secret.secret_id), disabled: disabled || readOnly || (!selected.includes(secret.secret_id) && selected.length >= 10), onCheckedChange: (checked) => onChange(checked === true ? [...selected, secret.secret_id] : selected.filter((value) => value !== secret.secret_id)) }), _jsx("span", { className: "text-foreground min-w-0 break-words", children: secret.label }), providerId === 'codex' && needsReconnection(secret) && (_jsx("span", { className: "text-muted-foreground ml-auto shrink-0 text-xs", children: t('needsReconnection') }))] }, secret.secret_id)) }), selected.length >= 10 && _jsx("p", { className: "text-muted-foreground text-xs", role: "status", children: t('selectionLimit') }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [configured && !readOnly && _jsx(Button, { size: "sm", variant: "secondary", disabled: disabled, onClick: onReset, children: t(personalChatGpt ? 'resetPersonalChatGptDefault' : 'resetDefault') }), providerId === 'codex'
63
+ : t(personalChatGpt ? 'personalChatGptDefault' : 'projectDefault') }), unavailable.length > 0 && _jsxs("div", { className: "space-y-1", role: "status", children: [_jsx("p", { className: "text-muted-foreground text-xs", children: t('unavailableKeys', { count: unavailable.length }) }), _jsx(Button, { size: "sm", variant: "outline-ghost", disabled: disabled || readOnly, onClick: () => onChange(selected.filter((secretId) => available.has(secretId))), children: t('removeUnavailable') })] }), _jsx("div", { className: "max-h-44 space-y-1 overflow-y-auto", children: keys.map((secret) => _jsxs("label", { className: "hover:bg-hover has-[:focus-visible]:ring-ring flex min-h-10 items-center gap-2 rounded-md px-2 py-2 text-sm has-[:focus-visible]:ring-2 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50", children: [_jsx(Checkbox, { checked: selectedIds.has(secret.secret_id), disabled: disabled || readOnly || (!selectedIds.has(secret.secret_id) && selected.length >= 10), onCheckedChange: (checked) => onChange(checked === true ? [...selected, secret.secret_id] : selected.filter((value) => value !== secret.secret_id)) }), _jsx("span", { className: "text-foreground min-w-0 break-words", children: secret.label }), providerId === 'codex' && needsReconnection(secret) && (_jsx("span", { className: "text-muted-foreground ml-auto shrink-0 text-xs", children: t('needsReconnection') }))] }, secret.secret_id)) }), selected.length >= 10 && _jsx("p", { className: "text-muted-foreground text-xs", role: "status", children: t('selectionLimit') }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [configured && !readOnly && _jsx(Button, { size: "sm", variant: "secondary", disabled: disabled, onClick: onReset, children: t(personalChatGpt ? 'resetPersonalChatGptDefault' : 'resetDefault') }), providerId === 'codex'
63
64
  ? _jsx(ChatGptAccountsButton, { projectId: projectId, variant: "outline-ghost", action: "manage", disabled: disabled })
64
65
  : !modelsHref
65
66
  ? null
@@ -24,13 +24,9 @@ export const AdvancedPanel = memo(function AdvancedPanel({ sessionId, messages,
24
24
  // 'live' follows the latest tool as new ones stream in; 'manual' pins the
25
25
  // user's chosen index until they navigate back to the latest.
26
26
  const [mode, setMode] = useState('live');
27
- // Live-follow + clamp when the list grows/shrinks.
28
- useEffect(() => {
29
- if (count === 0)
30
- return;
31
- setIndex((i) => (mode === 'live' ? count - 1 : Math.min(i, count - 1)));
32
- }, [count, mode]);
33
- const safeIndex = Math.min(index, Math.max(0, count - 1));
27
+ // Live-follow + clamp when the list grows/shrinks, derived in render so a
28
+ // streamed call never shows the previous one for a frame.
29
+ const safeIndex = Math.max(0, Math.min(mode === 'live' ? count - 1 : index, count - 1));
34
30
  const current = calls[safeIndex];
35
31
  const atLatest = safeIndex >= count - 1;
36
32
  const isLive = atLatest && mode === 'live';
@@ -40,7 +40,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore
40
40
  import { PreviewLoadingOverlay, SandboxAddressBar, } from '../../../browser/sandbox-browser-chrome.js';
41
41
  import { BrowserLanding } from '../../../browser/browser-landing.js';
42
42
  import { CloseButton, DetailSidebarToggle } from './detail-view.js';
43
- import { PREVIEW_MAX_WAIT_MS, PREVIEW_PROBE_INTERVAL_MS, previewErrorReason, previewLoadSuccessState, previewLoadVerdict, runtimeSandboxHealth, sandboxRecents, shouldArmLoadTimeout, shouldKeepProbingPort, } from './easy-panel-logic.js';
43
+ import { PREVIEW_MAX_WAIT_MS, PREVIEW_SLOW_MS, PREVIEW_PROBE_INTERVAL_MS, previewErrorReason, previewSlowReason, previewLoadSuccessState, previewLoadVerdict, runtimeSandboxHealth, sandboxRecents, shouldArmLoadTimeout, shouldKeepProbingPort, } from './easy-panel-logic.js';
44
44
  import { PanelWidthButton, ViewerActions } from '../../../files/viewer/viewer-actions.js';
45
45
  // zustand v5's own hook feeds React's `useSyncExternalStore` a
46
46
  // `getServerSnapshot` pinned to `getInitialState()` — correct for real SSR
@@ -94,6 +94,8 @@ export function AppPreview({ url, name, projectId, shareContext, onClose, onSend
94
94
  const [refreshKey, setRefreshKey] = useState(0);
95
95
  const [isLoading, setIsLoading] = useState(true);
96
96
  const [hasError, setHasError] = useState(false);
97
+ // Still loading past PREVIEW_SLOW_MS: the overlay says so and offers Reload.
98
+ const [slow, setSlow] = useState(false);
97
99
  const addressRef = useRef(null);
98
100
  const isMobile = useIsMobile();
99
101
  // Copy link mints a PUBLIC share and copies `/share/session/{token}`.
@@ -180,13 +182,21 @@ export function AppPreview({ url, name, projectId, shareContext, onClose, onSend
180
182
  if (alive)
181
183
  decide('unknown', PREVIEW_MAX_WAIT_MS);
182
184
  }, PREVIEW_MAX_WAIT_MS);
185
+ // A stalled request fires no onLoad and the probe cannot see it: say so
186
+ // instead of spinning silently until the deadline (KRTX-3015).
187
+ const slowTimer = setTimeout(() => {
188
+ if (alive)
189
+ setSlow(true);
190
+ }, PREVIEW_SLOW_MS);
183
191
  void tick();
184
192
  return () => {
185
193
  alive = false;
186
194
  controller.abort();
187
195
  clearTimeout(deadline);
196
+ clearTimeout(slowTimer);
188
197
  if (timer)
189
198
  clearTimeout(timer);
199
+ setSlow(false);
190
200
  };
191
201
  }, [isLoading, refreshKey, noApp, hasPreview, previewUrl]);
192
202
  // Nothing to load, so land the cursor on the address bar — the fastest way
@@ -234,7 +244,7 @@ export function AppPreview({ url, name, projectId, shareContext, onClose, onSend
234
244
  return;
235
245
  track('app_opened_new_tab');
236
246
  window.open(previewUrl, '_blank', 'noopener,noreferrer');
237
- }, className: "size-7 shrink-0 active:scale-[0.96]", children: _jsx(ArrowSquareOutIcon, { className: "size-3.5" }) }) }), _jsx(PanelWidthButton, { isMobile: isMobile }), _jsx(CloseButton, { onClose: onClose })] })] }), _jsxs("div", { className: "relative min-h-0 flex-1 overflow-hidden", children: [isLoading && hasPreview && !noApp && (_jsx(PreviewLoadingOverlay, { label: tI18nComplete.raw('textc4cf2b2ccb5d') })), (hasError || hostPreview.failed) && !noApp && (_jsx(ErrorState, { icon: WarningIcon, size: "sm", className: "bg-background absolute inset-0 z-10", title: tI18nComplete('textba601cdb484b', { value0: title }),
247
+ }, className: "size-7 shrink-0 active:scale-[0.96]", children: _jsx(ArrowSquareOutIcon, { className: "size-3.5" }) }) }), _jsx(PanelWidthButton, { isMobile: isMobile }), _jsx(CloseButton, { onClose: onClose })] })] }), _jsxs("div", { className: "relative min-h-0 flex-1 overflow-hidden", children: [isLoading && hasPreview && !noApp && (_jsx(PreviewLoadingOverlay, { label: tI18nComplete.raw('textc4cf2b2ccb5d'), detail: slow ? previewSlowReason({ port }) : undefined, action: slow ? (_jsx(Button, { variant: "outline", size: "sm", className: "gap-1.5", onClick: reload, children: tI18nComplete.raw('textbdc090ec61e3') })) : undefined })), (hasError || hostPreview.failed) && !noApp && (_jsx(ErrorState, { icon: WarningIcon, size: "sm", className: "bg-background absolute inset-0 z-10", title: tI18nComplete('textba601cdb484b', { value0: title }),
238
248
  /* The single most common cause, said plainly: the agent started the
239
249
  server a moment ago and it isn't listening yet. Only a SETTLED
240
250
  `dead` verdict earns the stopped-workspace wording — see
@@ -183,5 +183,5 @@ export declare function shouldAutoOpenPayoff(args: {
183
183
  interactedThisRun: boolean;
184
184
  }): boolean;
185
185
  export { aspectChangedWidth, fitSplitPercent, resolveSideSize } from '../session-panel-sizing.js';
186
- export { PREVIEW_MAX_WAIT_MS, PREVIEW_PROBE_INTERVAL_MS, PREVIEW_UNREACHABLE_WINDOW_MS, previewErrorReason, previewLoadSuccessState, previewLoadVerdict, runtimeSandboxHealth, shouldArmLoadTimeout, shouldKeepProbingPort } from './preview-load.js';
186
+ export { PREVIEW_MAX_WAIT_MS, PREVIEW_PROBE_INTERVAL_MS, PREVIEW_SLOW_MS, PREVIEW_UNREACHABLE_WINDOW_MS, previewErrorReason, previewLoadSuccessState, previewLoadVerdict, previewSlowReason, runtimeSandboxHealth, shouldArmLoadTimeout, shouldKeepProbingPort } from './preview-load.js';
187
187
  export type { PreviewProbe, SandboxHealth } from './preview-load.js';
@@ -238,4 +238,4 @@ export function shouldAutoOpenPayoff(args) {
238
238
  !args.interactedThisRun);
239
239
  }
240
240
  export { aspectChangedWidth, fitSplitPercent, resolveSideSize } from '../session-panel-sizing.js';
241
- export { PREVIEW_MAX_WAIT_MS, PREVIEW_PROBE_INTERVAL_MS, PREVIEW_UNREACHABLE_WINDOW_MS, previewErrorReason, previewLoadSuccessState, previewLoadVerdict, runtimeSandboxHealth, shouldArmLoadTimeout, shouldKeepProbingPort } from './preview-load.js';
241
+ export { PREVIEW_MAX_WAIT_MS, PREVIEW_PROBE_INTERVAL_MS, PREVIEW_SLOW_MS, PREVIEW_UNREACHABLE_WINDOW_MS, previewErrorReason, previewLoadSuccessState, previewLoadVerdict, previewSlowReason, runtimeSandboxHealth, shouldArmLoadTimeout, shouldKeepProbingPort } from './preview-load.js';
@@ -145,7 +145,8 @@ export function OutputRows({ outputs, onOpenOutput, initialShowAll, }) {
145
145
  const deliverables = outputs.filter((o) => !isScaffoldingOutput(o));
146
146
  const base = deliverables.length > 0 ? deliverables : outputs;
147
147
  const visible = showAll ? outputs : base.slice(0, VISIBLE_LIMIT);
148
- const hiddenRows = outputs.filter((o) => !visible.includes(o));
148
+ const visibleSet = new Set(visible);
149
+ const hiddenRows = outputs.filter((o) => !visibleSet.has(o));
149
150
  const hidden = hiddenRows.length;
150
151
  const hiddenNoun = `${hiddenRows.every((o) => o.kind === 'file') ? 'file' : 'item'}${hidden === 1 ? '' : 's'}`;
151
152
  // Grouping only ever organizes the expanded long list (see
@@ -18,7 +18,7 @@ import { Empty, EmptyContent, EmptyDescription, EmptyMedia } from '../../../prim
18
18
  import { cn } from '../../../lib/utils.js';
19
19
  import { CaretRightIcon as ChevronRight } from '@phosphor-icons/react';
20
20
  import { m, useReducedMotion } from 'motion/react';
21
- import { useEffect, useState } from 'react';
21
+ import { useState } from 'react';
22
22
  /** Full-width row trigger, clipped by the parent's `overflow-hidden` so its square corners never peek past the card's rounded-md border. */
23
23
  const HEADER_CLASS = cn('flex min-h-11 w-full items-center justify-between gap-2 rounded-none px-3.5 py-2 text-left', 'transition-[background-color,transform] active:scale-[0.998]', 'hover:bg-muted-foreground/[0.04]');
24
24
  function CardTitleRow({ title, count, indicator, subtitle, chevron, }) {
@@ -31,10 +31,12 @@ export function PanelCard({ title, count, indicator, subtitle, children, emptyAr
31
31
  // `defaultExpanded` can flip true after mount (e.g. "auto-open Outputs the
32
32
  // moment a run finishes with something to show"). One-way sync: force open
33
33
  // when that happens, but never fight a user's manual collapse afterwards.
34
- useEffect(() => {
34
+ const [prevDefaultExpanded, setPrevDefaultExpanded] = useState(defaultExpanded);
35
+ if (defaultExpanded !== prevDefaultExpanded) {
36
+ setPrevDefaultExpanded(defaultExpanded);
35
37
  if (defaultExpanded)
36
38
  setExpanded(true);
37
- }, [defaultExpanded]);
39
+ }
38
40
  return (_jsxs(Disclosure, { open: expanded, onOpenChange: setExpanded, variant: "outline", className: cn('bg-pane text-popover-foreground border-border overflow-hidden rounded-[calc(var(--radius)-3px)] border ease-out', fill ? 'flex min-h-0 flex-col' : 'shrink-0'), transition: transition, children: [_jsx(DisclosureTrigger, { variant: "outline", children: _jsx("div", { className: cn(HEADER_CLASS, 'cursor-pointer'), children: _jsx(CardTitleRow, { title: title, count: count, indicator: indicator, subtitle: subtitle, chevron: _jsxs("span", { className: "flex shrink-0 items-center gap-0.5", children: [headerAction && (
39
41
  // Isolated from the toggle on both input paths: `stopPropagation`
40
42
  // on click covers the mouse/tap case, and on keydown stops the
@@ -91,6 +91,20 @@ export declare const PREVIEW_UNREACHABLE_WINDOW_MS = 10000;
91
91
  * 50% margin. It is a last resort, not the normal path — with a working probe
92
92
  * a dead port resolves in ~{@link PREVIEW_UNREACHABLE_WINDOW_MS}. */
93
93
  export declare const PREVIEW_MAX_WAIT_MS = 90000;
94
+ /** How long the frame may load before the pane says it is slow, ms.
95
+ *
96
+ * A frame whose request stalls (the app has not answered yet, or a
97
+ * single-threaded server is busy with another client) fires no `onLoad`, and
98
+ * the port probe cannot see that. The pane then showed a bare "Loading
99
+ * preview…" until {@link PREVIEW_MAX_WAIT_MS} (KRTX-3015). Past this point it
100
+ * keeps waiting but says so, and offers Reload. Fifteen seconds is past the
101
+ * probe's {@link PREVIEW_UNREACHABLE_WINDOW_MS}, so a dead port has already
102
+ * failed by then, and well inside a cold compile, which only earns a line. */
103
+ export declare const PREVIEW_SLOW_MS = 15000;
104
+ /** The line under the loading spinner once the preview is slow. */
105
+ export declare function previewSlowReason(input: {
106
+ port: number;
107
+ }): string;
94
108
  /** One probe's verdict about the port. Aliased from the SDK's own type rather
95
109
  * than restated, so the two can never drift apart. */
96
110
  export type PreviewProbe = PreviewPortProbe;
@@ -63,6 +63,21 @@ export const PREVIEW_UNREACHABLE_WINDOW_MS = 10_000;
63
63
  * 50% margin. It is a last resort, not the normal path — with a working probe
64
64
  * a dead port resolves in ~{@link PREVIEW_UNREACHABLE_WINDOW_MS}. */
65
65
  export const PREVIEW_MAX_WAIT_MS = 90_000;
66
+ /** How long the frame may load before the pane says it is slow, ms.
67
+ *
68
+ * A frame whose request stalls (the app has not answered yet, or a
69
+ * single-threaded server is busy with another client) fires no `onLoad`, and
70
+ * the port probe cannot see that. The pane then showed a bare "Loading
71
+ * preview…" until {@link PREVIEW_MAX_WAIT_MS} (KRTX-3015). Past this point it
72
+ * keeps waiting but says so, and offers Reload. Fifteen seconds is past the
73
+ * probe's {@link PREVIEW_UNREACHABLE_WINDOW_MS}, so a dead port has already
74
+ * failed by then, and well inside a cold compile, which only earns a line. */
75
+ export const PREVIEW_SLOW_MS = 15_000;
76
+ /** The line under the loading spinner once the preview is slow. */
77
+ export function previewSlowReason(input) {
78
+ const app = input.port > 0 ? `The app on port ${input.port}` : 'The app';
79
+ return `${app} is taking a while to answer. Reload to ask again.`;
80
+ }
66
81
  /**
67
82
  * Whether `AppPreview` should keep waiting or show "Couldn't load".
68
83
  *
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useCallback, useEffect, useState } from 'react';
3
+ import { useCallback, useState } from 'react';
4
4
  import { ActionNavigator } from '../shared/action-navigator.js';
5
5
  import { clampIndex } from '../shared/action-navigator-logic.js';
6
6
  import { ToolParts } from './detail-view.js';
@@ -15,12 +15,7 @@ focusCallId, }) {
15
15
  // Arriving from a chat click pins the view to that call; 'live' would drag
16
16
  // it straight back to the newest part on the next streaming update.
17
17
  const [mode, setMode] = useState(focusIndex >= 0 ? 'manual' : 'live');
18
- useEffect(() => {
19
- if (calls.length === 0)
20
- return;
21
- setIndex((i) => (mode === 'live' ? calls.length - 1 : clampIndex(i, calls.length)));
22
- }, [calls.length, mode]);
23
- const safeIndex = clampIndex(index, calls.length);
18
+ const safeIndex = clampIndex(mode === 'live' ? calls.length - 1 : index, calls.length);
24
19
  const current = calls[safeIndex];
25
20
  const atLatest = safeIndex >= calls.length - 1;
26
21
  // Stable identity so ActionNavigator's keydown effect doesn't re-subscribe
@@ -30,6 +30,7 @@ import { SessionAuditPanel } from './session-audit-panel.js';
30
30
  import { SessionRemindersPanel } from './session-reminders-panel.js';
31
31
  import { SessionFilesExplorer } from './session-files-explorer.js';
32
32
  import { useSandboxProxy } from '../../internal/hooks/use-sandbox-proxy.js';
33
+ import { OutputPathsContext } from '../../markdown/code/output-paths-context.js';
33
34
  import { useIsMobile } from '../../hooks/use-mobile.js';
34
35
  import { track } from '../../lib/telemetry.js';
35
36
  import { parseLocalhostUrl } from '@kortix/sdk';
@@ -358,7 +359,6 @@ export function SessionPanelProvider({ sessionId, isSessionBusy = false, project
358
359
  });
359
360
  setPanelSplit(split);
360
361
  }, [
361
- sessionId,
362
362
  getServiceUrl,
363
363
  closeDetail,
364
364
  openDetail,
@@ -536,6 +536,10 @@ export function SessionPanelProvider({ sessionId, isSessionBusy = false, project
536
536
  detail,
537
537
  memoryKey,
538
538
  ]);
539
+ // Inline code that names an output by its bare filename opens it (KRTX-3023).
540
+ // Keyed by content, so a re-derivation with the same paths re-renders no text.
541
+ const outputPathsKey = files.flatMap((f) => (f.path ? [f.path] : [])).join('\n');
542
+ const outputPaths = useMemo(() => (outputPathsKey ? outputPathsKey.split('\n') : []), [outputPathsKey]);
539
543
  const value = useMemo(() => ({
540
544
  sessionId,
541
545
  projectId,
@@ -577,5 +581,5 @@ export function SessionPanelProvider({ sessionId, isSessionBusy = false, project
577
581
  openAudit,
578
582
  openReminders,
579
583
  ]);
580
- return _jsx(SessionPanelContext.Provider, { value: value, children: children });
584
+ return (_jsx(SessionPanelContext.Provider, { value: value, children: _jsx(OutputPathsContext.Provider, { value: outputPaths, children: children }) }));
581
585
  }
@@ -1,4 +1,4 @@
1
- import { useMemo, useRef } from 'react';
1
+ import { useLayoutEffect, useMemo, useRef } from 'react';
2
2
  import { useSessionLogThread } from '@kortix/sdk/react';
3
3
  import { samePartsList } from '../../turn/same-parts.js';
4
4
  import { deriveIsRunning } from '../easy/easy-panel-logic.js';
@@ -11,9 +11,11 @@ import { deriveRunOutcome } from './run-outcome.js';
11
11
  /** The previous list while its elements are unchanged, so memos keyed on it hold across a text delta. */
12
12
  function useSameList(next) {
13
13
  const ref = useRef(next);
14
- if (!samePartsList(ref.current, next))
15
- ref.current = next;
16
- return ref.current;
14
+ const list = samePartsList(ref.current, next) ? ref.current : next;
15
+ useLayoutEffect(() => {
16
+ ref.current = list;
17
+ });
18
+ return list;
17
19
  }
18
20
  export function usePanelDerivations({ sessionId, projectId, projectSessionId, isSessionBusy }) {
19
21
  // The root thread's messages: the `fromV1` live view of the v1 rows while
@@ -59,7 +59,7 @@ function usePanelTransitions(panelGroupRef) {
59
59
  panels.forEach((panel) => {
60
60
  panel.style.transition = 'flex 300ms cubic-bezier(0.4, 0, 0.2, 1)';
61
61
  });
62
- }, []);
62
+ }, [panelGroupRef]);
63
63
  const disablePanelTransition = useCallback(() => {
64
64
  const el = panelGroupRef.current;
65
65
  if (!el)
@@ -68,7 +68,7 @@ function usePanelTransitions(panelGroupRef) {
68
68
  panels.forEach((panel) => {
69
69
  panel.style.transition = 'none';
70
70
  });
71
- }, []);
71
+ }, [panelGroupRef]);
72
72
  return { enablePanelTransition, disablePanelTransition };
73
73
  }
74
74
  // Keep the box current so a measurement landing at any moment has a real
@@ -95,7 +95,7 @@ function usePanelBoxObserver({ isMobile, panelGroupRef, panelBoxRef, }) {
95
95
  });
96
96
  observer.observe(el);
97
97
  return () => observer.disconnect();
98
- }, [isMobile]);
98
+ }, [isMobile, panelBoxRef, panelGroupRef]);
99
99
  }
100
100
  function usePanelSideSize({ isExpanded, isEasy, panelSplit, panelAspect, panelBoxRef, }) {
101
101
  // Easy mode opens at the panel's MINIMUM width (35/65): the cards are a
@@ -125,7 +125,7 @@ function usePanelSideSize({ isExpanded, isEasy, panelSplit, panelAspect, panelBo
125
125
  panelAspect,
126
126
  panelSplit,
127
127
  panelBox: panelBoxRef.current,
128
- }), [isExpanded, isEasy, panelAspect, panelSplit]);
128
+ }), [isExpanded, isEasy, panelAspect, panelSplit, panelBoxRef]);
129
129
  const mainSize = 100 - sideSize;
130
130
  return { sideSize, mainSize };
131
131
  }
@@ -185,6 +185,8 @@ function usePanelGlide(input) {
185
185
  panelAspect,
186
186
  sideSize,
187
187
  mainSize,
188
+ mainPanelRef,
189
+ sidePanelRef,
188
190
  ]);
189
191
  return isAnimating;
190
192
  }
@@ -218,5 +220,5 @@ function usePanelGlideFrames({ isAnimating, enablePanelTransition, sideSize, mai
218
220
  mainPanelRef.current?.resize(mainSize);
219
221
  });
220
222
  return () => cancelAnimationFrame(raf);
221
- }, [isAnimating, enablePanelTransition, sideSize, mainSize]);
223
+ }, [isAnimating, enablePanelTransition, sideSize, mainSize, mainPanelRef, sidePanelRef]);
222
224
  }
@@ -14,16 +14,17 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
14
14
  * entry wins over the raw agent name.
15
15
  */
16
16
  import { useUiHostConfig } from '../../providers/ui-provider.js';
17
+ const printedAtFormat = new Intl.DateTimeFormat('en-GB', {
18
+ day: 'numeric',
19
+ month: 'long',
20
+ year: 'numeric',
21
+ hour: '2-digit',
22
+ minute: '2-digit',
23
+ hour12: false,
24
+ });
17
25
  /** `16 September 2026 at 20:41` — unambiguous across locales, no slashes. */
18
26
  export function formatPrintedAt(date) {
19
- return new Intl.DateTimeFormat('en-GB', {
20
- day: 'numeric',
21
- month: 'long',
22
- year: 'numeric',
23
- hour: '2-digit',
24
- minute: '2-digit',
25
- hour12: false,
26
- }).format(date);
27
+ return printedAtFormat.format(date);
27
28
  }
28
29
  /** The meta line under the title: the parts that exist, separated by a middot. */
29
30
  export function printHeaderMeta(input) {
@@ -389,7 +389,9 @@ export function SessionChat({ sessionId, projectSessionId, sessionState, project
389
389
  const messages = thread?.messages.length ? thread.messages : undefined;
390
390
  // The thread for handlers that read it at call time (the optimistic answers cache).
391
391
  const threadRef = useRef(thread);
392
- threadRef.current = thread;
392
+ useLayoutEffect(() => {
393
+ threadRef.current = thread;
394
+ }, [thread]);
393
395
  const messagesLoading = syncMessagesLoading;
394
396
  // Who wrote each user message. A one-person session stays unlabelled.
395
397
  const viewerId = useUiViewerId();
@@ -1285,6 +1287,7 @@ export function SessionChat({ sessionId, projectSessionId, sessionState, project
1285
1287
  autoLoadedPages,
1286
1288
  handleLoadOlder,
1287
1289
  scrollRef,
1290
+ spacerElRef,
1288
1291
  sessionId,
1289
1292
  transcriptOnLog,
1290
1293
  ]);
@@ -1506,7 +1509,9 @@ export function SessionChat({ sessionId, projectSessionId, sessionState, project
1506
1509
  const harnessSwitches = useMemo(() => sessionLogHarnessSwitches(sessionRecord?.harness?.history, turns), [sessionRecord?.harness?.history, turns]);
1507
1510
  // P5.6: load the whole of every text a turn shows as a preview, on request only.
1508
1511
  const fullTextsRef = useRef(loaded);
1509
- fullTextsRef.current = loaded;
1512
+ useLayoutEffect(() => {
1513
+ fullTextsRef.current = loaded;
1514
+ });
1510
1515
  const loadFullTexts = useCallback((turnId, refs) => {
1511
1516
  const key = fullTextsRef.current.key;
1512
1517
  const mark = (state) => setFullTexts((prev) => {
@@ -3201,6 +3206,7 @@ export function SessionChat({ sessionId, projectSessionId, sessionState, project
3201
3206
  ? 'duration-slow max-h-130 translate-y-0 opacity-100'
3202
3207
  : 'duration-slow pointer-events-none max-h-0 -translate-y-1 opacity-0'), children: _jsx(QuestionPrompt, { ref: questionPromptRef, request: renderedQuestion, onReply: handleQuestionReply, onReject: handleQuestionReject, onActionChange: handleQuestionActionChange }, renderedQuestion.id) })) : null] })), [
3203
3208
  sessionId,
3209
+ sessionScopeAgentName,
3204
3210
  pendingPermissions,
3205
3211
  handlePermissionReply,
3206
3212
  renderedQuestion,
@@ -3208,10 +3214,6 @@ export function SessionChat({ sessionId, projectSessionId, sessionState, project
3208
3214
  handleQuestionReply,
3209
3215
  handleQuestionReject,
3210
3216
  handleQuestionActionChange,
3211
- tHardcodedUi,
3212
- local.model.fallback,
3213
- local.model.list,
3214
- handleModelChange,
3215
3217
  ]);
3216
3218
  // The rewound-path notice lives on the composer toolbar, beside send/stop —
3217
3219
  // send is what commits the path, so the control sits at the moment of
@@ -3405,8 +3407,9 @@ export function SessionChat({ sessionId, projectSessionId, sessionState, project
3405
3407
  // Change requests are the only kind a turn produces. `external` exists on
3406
3408
  // the union for `setup-links/setup-link-button.tsx`, which renders this
3407
3409
  // card inline in prose and handles its own open.
3408
- if (outcome.kind === 'change_request')
3409
- setOpenCrId(outcome.id.slice('cr:'.length));
3410
+ if (outcome.kind === 'change_request') {
3411
+ setOpenCrId(outcome.action.target ?? outcome.id.slice('cr:'.length));
3412
+ }
3410
3413
  }, [setOpenCrId]);
3411
3414
  // While the session is still connecting / loading its content, render ONLY the
3412
3415
  // staged loader — never the session shell (header + input) at the same time.
@@ -3439,7 +3442,7 @@ export function SessionChat({ sessionId, projectSessionId, sessionState, project
3439
3442
  : welcomeWallpaper, !hideHeader && (_jsx(SessionSiteHeader, { sessionId: sessionId, sessionTitle: session?.title || 'Untitled', leadingAction: headerLeadingAction, parent: parentCrumb })), _jsx(SessionContextModal, { open: contextModalOpen, onOpenChange: setContextModalOpen,
3440
3443
  // A chat with no `useSession` source: the raw explorer lists the
3441
3444
  // rows `useSessionSync` read for it.
3442
- detachedRows: sessionState ? undefined : hookMessages, source: sessionState ?? DETACHED_SESSION_MESSAGES, session: session, providers: providers, allSessions: allSessions, servedModel: sessionServedModel, billedCost: sessionBilledCost(modelUsage) }), _jsx(CompactModal, { sessionId: sessionId, open: compactModalOpen, onOpenChange: setCompactModalOpen }), _jsx(ChangeRequestDetailDialog, { crId: openCrId, onClose: () => setOpenCrId(null) }), _jsxs(SessionBodyRow, { actionPanel: !hideHeader && !readOnly, children: [isNotFound ? (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-6 text-center", children: [_jsx("div", { className: "text-muted-foreground text-sm", children: tHardcodedUi.raw('componentsSessionSessionChat.line5821JsxTextThisSessionIsNotAccessibleRightNow') }), _jsx(Button, { asChild: true, variant: "outline", size: "sm", children: _jsx(Link, { href: "/", prefetch: true, onClick: () => {
3445
+ detachedRows: sessionState ? undefined : hookMessages, source: sessionState ?? DETACHED_SESSION_MESSAGES, session: session, providers: providers, allSessions: allSessions, servedModel: sessionServedModel, billedCost: sessionBilledCost(modelUsage) }), _jsx(CompactModal, { sessionId: sessionId, open: compactModalOpen, onOpenChange: setCompactModalOpen }), _jsx(ChangeRequestDetailDialog, { crId: openCrId, onClose: () => setOpenCrId(null), onOpenChangeRequest: setOpenCrId }), _jsxs(SessionBodyRow, { actionPanel: !hideHeader && !readOnly, children: [isNotFound ? (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-6 text-center", children: [_jsx("div", { className: "text-muted-foreground text-sm", children: tHardcodedUi.raw('componentsSessionSessionChat.line5821JsxTextThisSessionIsNotAccessibleRightNow') }), _jsx(Button, { asChild: true, variant: "outline", size: "sm", children: _jsx(Link, { href: "/", prefetch: true, onClick: () => {
3443
3446
  try {
3444
3447
  if (sessionId)
3445
3448
  useTabStore.getState().closeTab?.(sessionId);
@@ -15,6 +15,7 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
15
15
  */
16
16
  import { ArrowsLeftRightIcon, ClockCounterClockwiseIcon, XIcon } from '@phosphor-icons/react';
17
17
  import { sessionLogFullTextAction, sessionLogReferencedTexts, } from '@kortix/sdk';
18
+ import { useMemo } from 'react';
18
19
  import { Button } from '../../primitives/button.js';
19
20
  import { InfoBanner } from '../../primitives/info-banner.js';
20
21
  import { useLocale, useTranslations } from '../../i18n/index.js';
@@ -51,7 +52,8 @@ function missingLabel(t, kind, count) {
51
52
  export function RestoreNotice({ notice, onDismiss }) {
52
53
  const t = useTranslations('sessionUi.history');
53
54
  const locale = useLocale();
54
- const items = new Intl.ListFormat(locale, { style: 'narrow', type: 'unit' }).format(notice.missing.map((m) => missingLabel(t, m.kind, m.count)));
55
+ const listFormat = useMemo(() => new Intl.ListFormat(locale, { style: 'narrow', type: 'unit' }), [locale]);
56
+ const items = listFormat.format(notice.missing.map((m) => missingLabel(t, m.kind, m.count)));
55
57
  return (
56
58
  // `info`: the neutral tone keeps `Alert`'s default fill under a hairline (two boundaries).
57
59
  _jsxs(InfoBanner, { tone: "info", role: "status", "data-restore-notice": "", className: "mb-6 rounded-md", icon: ClockCounterClockwiseIcon, title: t('restoreTitle'), action: _jsx(Button, { variant: "ghost", size: "icon-sm", onClick: onDismiss, "aria-label": t('restoreDismiss'), className: "text-muted-foreground", children: _jsx(XIcon, { className: "size-4" }) }), children: [notice.missing.length > 0 && _jsxs("span", { children: [t('restoreMissing', { items }), " "] }), t('restoreAgent')] }));
@@ -43,6 +43,8 @@ import { Disclosure, DisclosureContent, DisclosureTrigger } from '../../primitiv
43
43
  import { useUserPreferencesStore } from '../../internal/stores/user-preferences-store.js';
44
44
  import { SubSessionModal } from './sub-session-modal.js';
45
45
  import { ToolPartRenderer, TurnLiveContext } from '../tool/tool-renderers.js';
46
+ import { RecoveredCallsContext } from '../tool/shared/infrastructure.js';
47
+ import { recoveredShellFailures } from '../turn/recovered-failures.js';
46
48
  import { cn } from '../../lib/utils.js';
47
49
  import { extractKortixSystemMessages, extractSessionReport, stripKortixSystemTags, } from '../../internal/lib/utils/kortix-system-tags.js';
48
50
  // Shared UI primitives (framework-agnostic, reusable on mobile)
@@ -959,6 +961,8 @@ function TurnUserBubble(props) {
959
961
  ? () => onRemoveQueued(pendingPrompt.prompt_id)
960
962
  : undefined })) : queuedStatus === 'sending' || queuedStatus === 'queued' || queuedStatus === 'interrupted' ? (_jsx(QueuedPromptProgress, { state: queuedStatus })) : undefined }) })) }));
961
963
  }
964
+ /** One shared empty set: a live turn has no recovered calls yet. */
965
+ const NO_RECOVERED_CALLS = new Set();
962
966
  /** The assistant's part tree: bursts, standalone deliverables, and prose
963
967
  * between them, under one live-context provider. */
964
968
  function TurnToolBlocks(props) {
@@ -972,6 +976,8 @@ function TurnToolBlocks(props) {
972
976
  // Settled turns only: while working the split is null anyway, and the
973
977
  // filter re-runs the burst steps on every SSE frame.
974
978
  const split = useMemo(() => (fold && !working ? splitSessionLogTurnWork(segments.filter(segmentRendersSomething), { working }) : null), [fold, segments, working]);
979
+ // Settled turns only: while the turn runs, the next step has not happened yet.
980
+ const recovered = useMemo(() => (working ? NO_RECOVERED_CALLS : recoveredShellFailures(turnBlocks(turn))), [turn, working]);
975
981
  const renderSegment = (segment) => {
976
982
  const key = turnSegmentKey(segment);
977
983
  if (key === null)
@@ -984,13 +990,13 @@ function TurnToolBlocks(props) {
984
990
  // will. Provided here rather than per-row because `working` is a fact
985
991
  // about the TURN, and this block is the turn's whole part tree.
986
992
  // See `TurnLiveContext`.
987
- _jsx(TurnLiveContext.Provider, { value: working, children: _jsx("div", { className: "space-y-3", children: split
988
- ? [
989
- _jsx(TurnWorkAccordion, { durationMs: durationMs, children: split.work.map(renderSegment) }, "turn-work"),
990
- ...split.hoisted.map(renderSegment),
991
- ...split.final.map(renderSegment),
992
- ]
993
- : segments.map(renderSegment) }) })) }));
993
+ _jsx(TurnLiveContext.Provider, { value: working, children: _jsx(RecoveredCallsContext.Provider, { value: recovered, children: _jsx("div", { className: "space-y-3", children: split
994
+ ? [
995
+ _jsx(TurnWorkAccordion, { durationMs: durationMs, children: split.work.map(renderSegment) }, "turn-work"),
996
+ ...split.hoisted.map(renderSegment),
997
+ ...split.final.map(renderSegment),
998
+ ]
999
+ : segments.map(renderSegment) }) }) })) }));
994
1000
  }
995
1001
  /** One segment of the turn's steps section: a burst, a show group, a
996
1002
  * standalone deliverable, or prose between bursts. Keyed by the first