@open-mercato/ui 0.6.1-develop.3291.1.6fad645fd0 → 0.6.1

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 (77) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -0
  3. package/dist/ai/AiAssistantLauncher.js +8 -8
  4. package/dist/ai/AiAssistantLauncher.js.map +2 -2
  5. package/dist/ai/AiChat.js +92 -93
  6. package/dist/ai/AiChat.js.map +2 -2
  7. package/dist/ai/AiIcon.js +17 -0
  8. package/dist/ai/AiIcon.js.map +7 -0
  9. package/dist/ai/LoopDisabledBanner.js +28 -0
  10. package/dist/ai/LoopDisabledBanner.js.map +7 -0
  11. package/dist/ai/LoopTracePanel.js +123 -0
  12. package/dist/ai/LoopTracePanel.js.map +7 -0
  13. package/dist/ai/ModelPicker.js.map +1 -1
  14. package/dist/ai/index.js +8 -0
  15. package/dist/ai/index.js.map +2 -2
  16. package/dist/ai/parts/ConfirmationCard.js +1 -5
  17. package/dist/ai/parts/ConfirmationCard.js.map +2 -2
  18. package/dist/ai/parts/FieldDiffCard.js +8 -15
  19. package/dist/ai/parts/FieldDiffCard.js.map +2 -2
  20. package/dist/ai/parts/MutationResultCard.js +1 -4
  21. package/dist/ai/parts/MutationResultCard.js.map +2 -2
  22. package/dist/ai/ui-parts/pending-phase3-placeholder.js +0 -2
  23. package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +2 -2
  24. package/dist/ai/useAiChat.js +18 -4
  25. package/dist/ai/useAiChat.js.map +2 -2
  26. package/dist/backend/CrudForm.js +24 -2
  27. package/dist/backend/CrudForm.js.map +2 -2
  28. package/dist/backend/DataTable.js +2 -2
  29. package/dist/backend/DataTable.js.map +2 -2
  30. package/dist/backend/confirm-dialog/ConfirmDialog.js +102 -96
  31. package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
  32. package/dist/backend/detail/CustomDataSection.js +22 -3
  33. package/dist/backend/detail/CustomDataSection.js.map +2 -2
  34. package/dist/backend/filters/AdvancedFilterBuilder.js +2 -2
  35. package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
  36. package/dist/backend/injection/ComponentOverrideProvider.js +1 -1
  37. package/dist/backend/injection/ComponentOverrideProvider.js.map +2 -2
  38. package/dist/backend/inputs/ComboboxInput.js +25 -7
  39. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  40. package/dist/backend/progress/useProgressPoll.js +58 -3
  41. package/dist/backend/progress/useProgressPoll.js.map +2 -2
  42. package/dist/backend/progress/useProgressSse.js +15 -2
  43. package/dist/backend/progress/useProgressSse.js.map +2 -2
  44. package/dist/backend/utils/bulkDelete.js +76 -0
  45. package/dist/backend/utils/bulkDelete.js.map +2 -2
  46. package/package.json +11 -6
  47. package/src/ai/AiAssistantLauncher.tsx +8 -8
  48. package/src/ai/AiChat.tsx +44 -38
  49. package/src/ai/AiIcon.tsx +12 -0
  50. package/src/ai/LoopDisabledBanner.tsx +38 -0
  51. package/src/ai/LoopTracePanel.tsx +184 -0
  52. package/src/ai/ModelPicker.tsx +1 -1
  53. package/src/ai/__tests__/AiChat.test.tsx +174 -5
  54. package/src/ai/index.ts +8 -0
  55. package/src/ai/parts/ConfirmationCard.tsx +1 -5
  56. package/src/ai/parts/FieldDiffCard.tsx +0 -3
  57. package/src/ai/parts/MutationResultCard.tsx +1 -4
  58. package/src/ai/parts/__tests__/MutationResultCard.test.tsx +5 -0
  59. package/src/ai/ui-parts/pending-phase3-placeholder.tsx +0 -2
  60. package/src/ai/useAiChat.ts +35 -5
  61. package/src/backend/CrudForm.tsx +30 -3
  62. package/src/backend/DataTable.tsx +2 -2
  63. package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +72 -0
  64. package/src/backend/__tests__/CustomDataSection.test.tsx +76 -0
  65. package/src/backend/__tests__/DataTable.render.test.tsx +36 -0
  66. package/src/backend/__tests__/component-replacement.test.tsx +44 -0
  67. package/src/backend/confirm-dialog/ConfirmDialog.tsx +101 -91
  68. package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +19 -0
  69. package/src/backend/detail/CustomDataSection.tsx +26 -3
  70. package/src/backend/filters/AdvancedFilterBuilder.tsx +6 -6
  71. package/src/backend/injection/ComponentOverrideProvider.tsx +2 -1
  72. package/src/backend/inputs/ComboboxInput.tsx +29 -7
  73. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +33 -2
  74. package/src/backend/progress/useProgressPoll.ts +68 -3
  75. package/src/backend/progress/useProgressSse.ts +16 -2
  76. package/src/backend/utils/__tests__/bulkDelete.test.ts +42 -0
  77. package/src/backend/utils/bulkDelete.ts +93 -0
package/src/ai/AiChat.tsx CHANGED
@@ -49,6 +49,7 @@ import {
49
49
  } from './useAiChat'
50
50
  import { useAiChatUpload } from './useAiChatUpload'
51
51
  import { useAiShortcuts } from './useAiShortcuts'
52
+ import { LoopTracePanel, type LoopTracePanelTrace } from './LoopTracePanel'
52
53
 
53
54
  // Cap inline previews so we do not blow past localStorage quota (~5MB on most
54
55
  // browsers). Images larger than this still upload + send to the LLM as inline
@@ -95,7 +96,8 @@ function writeModelPickerValue(agentId: string, value: ModelPickerValue | null):
95
96
 
96
97
  interface ModelsApiResponse {
97
98
  agentId: string
98
- allowRuntimeModelOverride: boolean
99
+ allowRuntimeOverride: boolean
100
+ allowRuntimeModelOverride?: boolean
99
101
  defaultProviderId: string | null
100
102
  defaultModelId: string | null
101
103
  defaultProviderName?: string | null
@@ -105,12 +107,13 @@ interface ModelsApiResponse {
105
107
 
106
108
  function useAgentModels(agent: string): {
107
109
  providers: ModelPickerProvider[]
110
+ allowRuntimeOverride: boolean
108
111
  allowRuntimeModelOverride: boolean
109
112
  defaultLabel: string | null
110
113
  loaded: boolean
111
114
  } {
112
115
  const [providers, setProviders] = React.useState<ModelPickerProvider[]>([])
113
- const [allowRuntimeModelOverride, setAllowRuntimeModelOverride] = React.useState(false)
116
+ const [allowRuntimeOverride, setAllowRuntimeOverride] = React.useState(false)
114
117
  const [defaultLabel, setDefaultLabel] = React.useState<string | null>(null)
115
118
  const [loaded, setLoaded] = React.useState(false)
116
119
 
@@ -123,7 +126,9 @@ function useAgentModels(agent: string): {
123
126
  setLoaded(true)
124
127
  return
125
128
  }
126
- setAllowRuntimeModelOverride(result.result.allowRuntimeModelOverride)
129
+ const effectiveAllowRuntimeOverride =
130
+ result.result.allowRuntimeOverride ?? result.result.allowRuntimeModelOverride ?? false
131
+ setAllowRuntimeOverride(effectiveAllowRuntimeOverride)
127
132
  setProviders(result.result.providers)
128
133
  setDefaultLabel(
129
134
  result.result.defaultProviderName && result.result.defaultModelName
@@ -136,19 +141,13 @@ function useAgentModels(agent: string): {
136
141
  })
137
142
  }, [agent])
138
143
 
139
- return { providers, allowRuntimeModelOverride, defaultLabel, loaded }
140
- }
141
-
142
- function firstAvailableModelPickerValue(
143
- providers: ModelPickerProvider[],
144
- ): ModelPickerValue | null {
145
- for (const provider of providers) {
146
- const model = provider.models[0]
147
- if (model) {
148
- return { providerId: provider.id, modelId: model.id }
149
- }
144
+ return {
145
+ providers,
146
+ allowRuntimeOverride,
147
+ allowRuntimeModelOverride: allowRuntimeOverride,
148
+ defaultLabel,
149
+ loaded,
150
150
  }
151
- return null
152
151
  }
153
152
 
154
153
  function isModelPickerValueAvailable(
@@ -263,6 +262,13 @@ export interface AiChatProps {
263
262
  * `debug` is falsy.
264
263
  */
265
264
  debugPromptSections?: AiChatDebugPromptSection[]
265
+ /**
266
+ * Optional loop trace from the last completed turn. When present and
267
+ * `debug` is truthy, renders a `LoopTracePanel` inside the debug panel.
268
+ * Populated by the host from the dispatcher SSE `loop-finish` event
269
+ * (wired in l4.4). Ignored when `debug` is falsy.
270
+ */
271
+ loopTrace?: LoopTracePanelTrace
266
272
  /** Suggested prompts shown in the empty / welcome state. */
267
273
  suggestions?: AiChatSuggestion[]
268
274
  /** Context items shown as pills above the transcript (e.g. selected products). */
@@ -381,6 +387,10 @@ function ToolCallList({ toolCalls }: { toolCalls: AiChatToolCallSnapshot[] }) {
381
387
  if (!toolCalls || toolCalls.length === 0) return null
382
388
  return (
383
389
  <div className="space-y-1" data-ai-chat-tool-calls="">
390
+ <div className="flex items-center justify-between px-0.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
391
+ <span>{t('ai_assistant.chat.agentTasksTitle', 'Agent tasks')}</span>
392
+ <span>{toolCalls.length}</span>
393
+ </div>
384
394
  {toolCalls.map((call) => {
385
395
  const isOpen = openId === call.id
386
396
  const isError = call.state === 'error'
@@ -828,6 +838,7 @@ export function AiChat({
828
838
  uiParts: uiPartsProp,
829
839
  debugTools,
830
840
  debugPromptSections,
841
+ loopTrace,
831
842
  conversationId,
832
843
  suggestions,
833
844
  contextItems,
@@ -892,6 +903,7 @@ export function AiChat({
892
903
 
893
904
  const {
894
905
  providers: modelProviders,
906
+ allowRuntimeOverride,
895
907
  allowRuntimeModelOverride,
896
908
  defaultLabel: modelDefaultLabel,
897
909
  loaded: modelProvidersLoaded,
@@ -902,15 +914,15 @@ export function AiChat({
902
914
  )
903
915
 
904
916
  const effectiveModelPickerValue = React.useMemo(() => {
905
- if (!modelProvidersLoaded || !allowRuntimeModelOverride || modelProviders.length === 0) {
917
+ if (!modelProvidersLoaded || !allowRuntimeOverride || modelProviders.length === 0) {
906
918
  return null
907
919
  }
908
920
  if (modelPickerValue && isModelPickerValueAvailable(modelPickerValue, modelProviders)) {
909
921
  return modelPickerValue
910
922
  }
911
- return firstAvailableModelPickerValue(modelProviders)
923
+ return null
912
924
  }, [
913
- allowRuntimeModelOverride,
925
+ allowRuntimeOverride,
914
926
  modelPickerValue,
915
927
  modelProviders,
916
928
  modelProvidersLoaded,
@@ -930,9 +942,8 @@ export function AiChat({
930
942
  return
931
943
  }
932
944
  if (modelPickerValue && !isModelPickerValueAvailable(modelPickerValue, modelProviders)) {
933
- const fallback = firstAvailableModelPickerValue(modelProviders)
934
- setModelPickerValue(fallback)
935
- writeModelPickerValue(agent, fallback)
945
+ setModelPickerValue(null)
946
+ writeModelPickerValue(agent, null)
936
947
  }
937
948
  }, [
938
949
  agent,
@@ -979,7 +990,9 @@ export function AiChat({
979
990
  // (d) streaming, and the last visible event was a finished tool call
980
991
  // — the model is reasoning about the result before emitting more
981
992
  // text or kicking off the next tool
982
- // (e) streaming, but no delta has landed in the last ~300 ms (idle gap)
993
+ // Once visible text/reasoning exists and no tool is pending, hide the
994
+ // placeholder even if the network stream stays open for a tail event. That
995
+ // avoids showing "Thinking..." under an already usable answer.
983
996
  const lastAssistant = React.useMemo(() => {
984
997
  for (let index = chat.messages.length - 1; index >= 0; index -= 1) {
985
998
  const candidate = chat.messages[index]
@@ -1014,29 +1027,16 @@ export function AiChat({
1014
1027
  // eslint-disable-next-line react-hooks/exhaustive-deps
1015
1028
  }, [lastAssistant])
1016
1029
 
1017
- const lastStreamUpdateRef = React.useRef<number>(Date.now())
1018
1030
  const lastSnapshotRef = React.useRef<string>('')
1019
1031
  const [, setStreamTick] = React.useState(0)
1020
1032
 
1021
1033
  React.useEffect(() => {
1022
1034
  if (assistantStreamSnapshot !== lastSnapshotRef.current) {
1023
1035
  lastSnapshotRef.current = assistantStreamSnapshot
1024
- lastStreamUpdateRef.current = Date.now()
1025
1036
  setStreamTick((value) => value + 1)
1026
1037
  }
1027
1038
  }, [assistantStreamSnapshot])
1028
1039
 
1029
- React.useEffect(() => {
1030
- if (!isStreaming && !isSubmitting) return
1031
- const interval = window.setInterval(() => {
1032
- setStreamTick((value) => value + 1)
1033
- }, 200)
1034
- return () => window.clearInterval(interval)
1035
- }, [isStreaming, isSubmitting])
1036
-
1037
- const idleDuringStream =
1038
- isStreaming && Date.now() - lastStreamUpdateRef.current >= 300
1039
-
1040
1040
  const showThinkingIndicator =
1041
1041
  isSubmitting ||
1042
1042
  (isStreaming &&
@@ -1044,8 +1044,7 @@ export function AiChat({
1044
1044
  !hasAnyVisibleSignal ||
1045
1045
  hasPendingToolCall ||
1046
1046
  // Tool just returned and the model hasn't started speaking yet.
1047
- (hasCompletedToolCall && !trimmedContent) ||
1048
- idleDuringStream
1047
+ (hasCompletedToolCall && !trimmedContent)
1049
1048
  ))
1050
1049
 
1051
1050
  const activeRegistry = registry ?? defaultAiUiPartRegistry
@@ -1429,7 +1428,7 @@ export function AiChat({
1429
1428
  >
1430
1429
  <Paperclip className="size-4" aria-hidden />
1431
1430
  </IconButton>
1432
- {allowRuntimeModelOverride && modelProviders.length > 0 ? (
1431
+ {allowRuntimeOverride && modelProviders.length > 0 ? (
1433
1432
  <ModelPicker
1434
1433
  agentId={agent}
1435
1434
  value={effectiveModelPickerValue}
@@ -1499,6 +1498,7 @@ export function AiChat({
1499
1498
  <AiChatDebugPanel
1500
1499
  tools={debugTools}
1501
1500
  promptSections={debugPromptSections}
1501
+ loopTrace={loopTrace ?? chat.lastLoopTrace ?? undefined}
1502
1502
  lastRequestDebug={chat.lastRequestDebug}
1503
1503
  lastResponseDebug={chat.lastResponseDebug}
1504
1504
  status={chat.status}
@@ -1512,6 +1512,7 @@ export function AiChat({
1512
1512
  interface DebugPanelProps {
1513
1513
  tools?: AiChatDebugTool[]
1514
1514
  promptSections?: AiChatDebugPromptSection[]
1515
+ loopTrace?: LoopTracePanelTrace
1515
1516
  lastRequestDebug: { url: string; body: unknown } | null
1516
1517
  lastResponseDebug: { status: number; text: string } | null
1517
1518
  status: 'idle' | 'submitting' | 'streaming'
@@ -1521,6 +1522,7 @@ interface DebugPanelProps {
1521
1522
  function AiChatDebugPanel({
1522
1523
  tools,
1523
1524
  promptSections,
1525
+ loopTrace,
1524
1526
  lastRequestDebug,
1525
1527
  lastResponseDebug,
1526
1528
  status,
@@ -1536,6 +1538,10 @@ function AiChatDebugPanel({
1536
1538
  {t('ai_assistant.chat.debug.panelTitle', 'Debug panel')}
1537
1539
  </div>
1538
1540
 
1541
+ {loopTrace ? (
1542
+ <LoopTracePanel trace={loopTrace} />
1543
+ ) : null}
1544
+
1539
1545
  <details className="rounded border border-border bg-background" data-ai-chat-debug-section="tools" open>
1540
1546
  <summary className="cursor-pointer px-2 py-1 font-semibold">
1541
1547
  {t('ai_assistant.chat.debug.toolsSection', 'Resolved tools')}
@@ -0,0 +1,12 @@
1
+ import { Sparkles, type LucideProps } from 'lucide-react'
2
+ import { cn } from '@open-mercato/shared/lib/utils'
3
+
4
+ export function AiIcon({ className, ...props }: LucideProps) {
5
+ return (
6
+ <Sparkles
7
+ aria-hidden
8
+ {...props}
9
+ className={cn(className, 'text-brand-violet')}
10
+ />
11
+ )
12
+ }
@@ -0,0 +1,38 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { ShieldOff } from 'lucide-react'
5
+ import { Alert, AlertDescription, AlertTitle } from '../primitives/alert'
6
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+
8
+ /**
9
+ * Banner displayed when the tenant kill switch (`loop_disabled = true`) is
10
+ * active for the current agent. Rendered on the playground page and on
11
+ * `<AiChat>` headers so operators are not blindsided (R11 mitigation, Phase 3
12
+ * of spec `2026-04-28-ai-agents-agentic-loop-controls`).
13
+ *
14
+ * The agent still completes turns but as single-step runs. The banner is
15
+ * informational only — operators must visit the Loop policy section of the
16
+ * AI assistant settings to re-enable the loop.
17
+ */
18
+ export function LoopDisabledBanner({ agentId }: { agentId?: string }) {
19
+ const t = useT()
20
+ return (
21
+ <Alert
22
+ variant="warning"
23
+ icon={<ShieldOff aria-hidden="true" />}
24
+ data-ai-loop-disabled-banner
25
+ data-ai-loop-disabled-agent-id={agentId}
26
+ >
27
+ <AlertTitle>
28
+ {t('ai_assistant.loop.disabledBanner.title', 'Agent loop disabled by tenant policy')}
29
+ </AlertTitle>
30
+ <AlertDescription>
31
+ {t(
32
+ 'ai_assistant.loop.disabledBanner.description',
33
+ 'The agentic loop has been disabled for this agent by a tenant administrator. Each turn runs as a single model call. To re-enable the loop, update the Loop policy in AI assistant settings.',
34
+ )}
35
+ </AlertDescription>
36
+ </Alert>
37
+ )
38
+ }
@@ -0,0 +1,184 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { ChevronDown, ChevronRight, Wrench } from 'lucide-react'
5
+
6
+ export interface LoopTracePanelStepRecord {
7
+ stepIndex: number
8
+ modelId: string
9
+ toolCalls: Array<{
10
+ toolName: string
11
+ args: unknown
12
+ result?: unknown
13
+ error?: { code: string; message: string }
14
+ repairAttempted: boolean
15
+ durationMs: number
16
+ }>
17
+ textDelta: string
18
+ usage: { inputTokens: number; outputTokens: number }
19
+ finishReason: 'stop' | 'tool-calls' | 'length' | 'content-filter' | 'error'
20
+ }
21
+
22
+ export interface LoopTracePanelTrace {
23
+ agentId: string
24
+ turnId: string
25
+ steps: LoopTracePanelStepRecord[]
26
+ stopReason:
27
+ | 'step-count'
28
+ | 'has-tool-call'
29
+ | 'custom-stop'
30
+ | 'budget-tokens'
31
+ | 'budget-tool-calls'
32
+ | 'budget-wall-clock'
33
+ | 'tenant-disabled'
34
+ | 'finish-reason'
35
+ | 'abort'
36
+ totalDurationMs: number
37
+ totalUsage: { inputTokens: number; outputTokens: number }
38
+ }
39
+
40
+ type LoopStepRecord = LoopTracePanelStepRecord
41
+ type LoopTrace = LoopTracePanelTrace
42
+
43
+ function formatMs(ms: number): string {
44
+ if (ms < 1000) return `${ms}ms`
45
+ return `${(ms / 1000).toFixed(1)}s`
46
+ }
47
+
48
+ function LoopStepRow({ step }: { step: LoopStepRecord }) {
49
+ const [expanded, setExpanded] = React.useState(false)
50
+ const hasTools = step.toolCalls.length > 0
51
+
52
+ return (
53
+ <li className="border-b border-border last:border-0">
54
+ <button
55
+ type="button"
56
+ className="flex w-full items-center gap-2 px-2 py-1.5 text-xs hover:bg-muted/40"
57
+ onClick={() => setExpanded((prev) => !prev)}
58
+ aria-expanded={expanded}
59
+ aria-label={`Step ${step.stepIndex}`}
60
+ >
61
+ {expanded ? (
62
+ <ChevronDown className="size-3 shrink-0 text-muted-foreground" aria-hidden />
63
+ ) : (
64
+ <ChevronRight className="size-3 shrink-0 text-muted-foreground" aria-hidden />
65
+ )}
66
+ <span className="font-medium text-muted-foreground">Step {step.stepIndex}</span>
67
+ <span className="font-mono text-muted-foreground/60">{step.modelId}</span>
68
+ {hasTools ? (
69
+ <span className="ml-auto flex items-center gap-1 text-muted-foreground">
70
+ <Wrench className="size-3" aria-hidden />
71
+ {step.toolCalls.length}
72
+ </span>
73
+ ) : null}
74
+ <span className="ml-auto font-mono text-muted-foreground/60">{step.finishReason}</span>
75
+ <span className="font-mono text-muted-foreground/60">
76
+ {step.usage.inputTokens}↑ {step.usage.outputTokens}↓
77
+ </span>
78
+ </button>
79
+
80
+ {expanded ? (
81
+ <div className="bg-muted/20 px-4 pb-2 pt-1 text-xs">
82
+ {step.textDelta ? (
83
+ <div className="mb-2">
84
+ <div className="mb-0.5 font-semibold text-muted-foreground">Text</div>
85
+ <pre className="max-h-32 overflow-auto whitespace-pre-wrap rounded bg-background p-1.5 font-mono">
86
+ {step.textDelta}
87
+ </pre>
88
+ </div>
89
+ ) : null}
90
+ {hasTools ? (
91
+ <div>
92
+ <div className="mb-0.5 font-semibold text-muted-foreground">Tool calls</div>
93
+ <ul className="flex flex-col gap-1">
94
+ {step.toolCalls.map((tc, index) => (
95
+ <li
96
+ key={index}
97
+ className="rounded border border-border bg-background p-1.5"
98
+ data-loop-trace-tool-call={tc.toolName}
99
+ >
100
+ <div className="flex items-center justify-between gap-2">
101
+ <span className="font-mono font-semibold">{tc.toolName}</span>
102
+ {tc.durationMs ? (
103
+ <span className="text-muted-foreground/60">{formatMs(tc.durationMs)}</span>
104
+ ) : null}
105
+ {tc.error ? (
106
+ <span className="text-status-error-fg">error: {tc.error.code}</span>
107
+ ) : null}
108
+ </div>
109
+ {tc.args !== undefined ? (
110
+ <pre className="mt-1 max-h-24 overflow-auto whitespace-pre-wrap font-mono text-xs text-muted-foreground">
111
+ {JSON.stringify(tc.args, null, 2)}
112
+ </pre>
113
+ ) : null}
114
+ </li>
115
+ ))}
116
+ </ul>
117
+ </div>
118
+ ) : null}
119
+ </div>
120
+ ) : null}
121
+ </li>
122
+ )
123
+ }
124
+
125
+ export interface LoopTracePanelProps {
126
+ trace: LoopTracePanelTrace
127
+ className?: string
128
+ }
129
+
130
+ /**
131
+ * Collapsible playground / debug panel that renders a `LoopTrace` per turn.
132
+ * Collapsed by default; each step row is independently expandable.
133
+ *
134
+ * Phase 4 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
135
+ */
136
+ export function LoopTracePanel({ trace, className }: LoopTracePanelProps) {
137
+ const [open, setOpen] = React.useState(false)
138
+
139
+ return (
140
+ <section
141
+ className={`rounded-md border border-border bg-background text-xs${className ? ` ${className}` : ''}`}
142
+ data-ai-loop-trace-panel={trace.agentId}
143
+ >
144
+ <button
145
+ type="button"
146
+ className="flex w-full items-center gap-2 px-3 py-2 hover:bg-muted/40"
147
+ onClick={() => setOpen((prev) => !prev)}
148
+ aria-expanded={open}
149
+ aria-controls="loop-trace-steps"
150
+ >
151
+ {open ? (
152
+ <ChevronDown className="size-3 shrink-0 text-muted-foreground" aria-hidden />
153
+ ) : (
154
+ <ChevronRight className="size-3 shrink-0 text-muted-foreground" aria-hidden />
155
+ )}
156
+ <span className="font-semibold text-muted-foreground">Loop trace</span>
157
+ <span className="ml-1 font-mono text-muted-foreground/60">
158
+ {trace.steps.length} step{trace.steps.length !== 1 ? 's' : ''}
159
+ </span>
160
+ <span
161
+ className="ml-auto font-mono text-muted-foreground/60"
162
+ data-loop-trace-stop-reason={trace.stopReason}
163
+ >
164
+ {trace.stopReason}
165
+ </span>
166
+ <span className="font-mono text-muted-foreground/60">{formatMs(trace.totalDurationMs)}</span>
167
+ <span className="font-mono text-muted-foreground/60">
168
+ {trace.totalUsage.inputTokens + trace.totalUsage.outputTokens} tok
169
+ </span>
170
+ </button>
171
+
172
+ {open ? (
173
+ <ul id="loop-trace-steps" className="border-t border-border" role="list">
174
+ {trace.steps.map((step) => (
175
+ <LoopStepRow key={step.stepIndex} step={step} />
176
+ ))}
177
+ {trace.steps.length === 0 ? (
178
+ <li className="px-3 py-2 text-muted-foreground">No steps recorded.</li>
179
+ ) : null}
180
+ </ul>
181
+ ) : null}
182
+ </section>
183
+ )
184
+ }
@@ -47,7 +47,7 @@ export interface ModelPickerProps {
47
47
  * - Does NOT expose a baseURL input (R6 mitigation: only provider+model selection from the
48
48
  * curated catalog is exposed to end users).
49
49
  * - localStorage persistence keyed by agentId is handled by the consumer (AiChat / 4b.2).
50
- * - When allowRuntimeModelOverride is false the consumer should not render this component
50
+ * - When allowRuntimeOverride is false the consumer should not render this component
51
51
  * at all; this component itself does no gating.
52
52
  */
53
53
  export function ModelPicker({