@puredesktop/puredesktop-ui-bridge 2.1.8 → 2.1.9

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 (75) hide show
  1. package/package.json +35 -5
  2. package/src/agents/mcpServerPresets.test.ts +105 -0
  3. package/src/agents/mcpServerPresets.ts +380 -0
  4. package/src/agents/runtime/index.ts +7 -0
  5. package/src/agents/runtime/mapAgentSnapshot.ts +11 -8
  6. package/src/agents/runtime/pendingAsks.test.ts +117 -0
  7. package/src/agents/runtime/pendingAsks.ts +110 -0
  8. package/src/bridge/agentScopeDefaults.ts +65 -0
  9. package/src/bridge/agentTypes.ts +36 -2
  10. package/src/bridge/agents.d.mts +6 -0
  11. package/src/bridge/agents.mjs +10 -0
  12. package/src/bridge/agents.ts +4 -0
  13. package/src/bridge/credentials.d.mts +62 -0
  14. package/src/bridge/credentials.mjs +53 -0
  15. package/src/bridge/events.d.mts +1 -0
  16. package/src/bridge/events.mjs +1 -0
  17. package/src/bridge/googleAuth.ts +7 -152
  18. package/src/bridge/mcpClient.d.mts +74 -0
  19. package/src/bridge/mcpClient.mjs +79 -0
  20. package/src/bridge/methods.d.mts +22 -0
  21. package/src/bridge/methods.mjs +23 -0
  22. package/src/bridge/notifications.d.mts +12 -0
  23. package/src/bridge/notifications.mjs +36 -0
  24. package/src/bridge/operations.d.mts +44 -0
  25. package/src/bridge/operations.mjs +40 -0
  26. package/src/bridge/react/usePlatformAgentSessionDrive.test.tsx +29 -10
  27. package/src/bridge/react/usePlatformAgentSessionDrive.tsx +10 -81
  28. package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -105
  29. package/src/bridge/react/usePlatformAppSettings.tsx +104 -104
  30. package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -152
  31. package/src/bridge/react/usePlatformJsonStore.tsx +149 -149
  32. package/src/bridge/react/usePlatformOperations.tsx +75 -0
  33. package/src/bridge/storage.d.mts +17 -4
  34. package/src/bridge/storage.mjs +6 -3
  35. package/src/components/agents/AgentComposer.tsx +10 -6
  36. package/src/components/agents/AgentDrawerPanel.test.tsx +2 -205
  37. package/src/components/agents/AgentDrawerPanel.tsx +23 -64
  38. package/src/components/agents/AgentMessageBubble.tsx +59 -33
  39. package/src/components/agents/AgentMessageList.tsx +4 -9
  40. package/src/components/agents/AgentQuestionPromptList.tsx +34 -212
  41. package/src/components/agents/AgentToolApprovalActions.tsx +13 -11
  42. package/src/components/agents/AgentToolPendingList.tsx +6 -6
  43. package/src/components/agents/AgentToolPendingReview.tsx +6 -6
  44. package/src/components/agents/QuestionRequestList.tsx +278 -0
  45. package/src/components/agents/agentPanelStyles.ts +9 -33
  46. package/src/components/agents/agentTypes.ts +25 -43
  47. package/src/components/agents/index.ts +4 -0
  48. package/src/components/assets/asset-library/sidebar/AssetLibraryList.tsx +10 -4
  49. package/src/components/chrome/WorkspaceTabStrip.tsx +4 -3
  50. package/src/components/common/chat/ChatBox.tsx +9 -9
  51. package/src/components/common/chat/ChatModelMenu.tsx +34 -4
  52. package/src/components/common/chat/ChatThread.tsx +23 -7
  53. package/src/components/common/connections/ProviderConnection.test.tsx +208 -0
  54. package/src/components/common/connections/ProviderConnection.tsx +277 -0
  55. package/src/components/common/connections/index.ts +5 -0
  56. package/src/components/common/containers/AppFrame.tsx +26 -19
  57. package/src/components/common/containers/AppHeader.tsx +20 -20
  58. package/src/components/common/documents/DocumentSwitcher.tsx +63 -58
  59. package/src/components/common/dropdown/MenuDropdownItem.tsx +14 -2
  60. package/src/components/common/dropdown/MenuPortal.tsx +3 -1
  61. package/src/components/common/dropdown/menuViewportPosition.test.ts +20 -10
  62. package/src/components/common/dropdown/menuViewportPosition.ts +35 -27
  63. package/src/components/common/overlays/Modal.tsx +13 -2
  64. package/src/components/editor/EditorLinkPromptDialog.tsx +1 -1
  65. package/src/ics/generateIcs.test.ts +153 -0
  66. package/src/ics/generateIcs.ts +197 -0
  67. package/src/index.ts +3 -0
  68. package/src/net/httpRetry.test.ts +117 -0
  69. package/src/net/httpRetry.ts +111 -0
  70. package/src/theme/appAccents.ts +6 -0
  71. package/src/theme/appIdentityCss.mjs +11 -0
  72. package/src/theme/appIdentityCss.ts +11 -0
  73. package/src/theme/themes/dark.ts +3 -1
  74. package/src/theme/themes/light.ts +3 -1
  75. package/src/bridge/googleProviderConfig.ts +0 -73
@@ -8,17 +8,30 @@ export interface PlatformStorageJsonRequest {
8
8
  export interface PlatformStorageJsonReadResult {
9
9
  path: string
10
10
  value: unknown | null
11
+ /** Content hash for optimistic concurrency (null when the file is absent). */
12
+ version: string | null
11
13
  }
12
14
 
13
15
  export interface PlatformStorageJsonWriteRequest
14
16
  extends PlatformStorageJsonRequest {
15
17
  value: unknown
18
+ /**
19
+ * The version this write was derived from (null = file must not exist).
20
+ * Omit for an unconditional write. On mismatch nothing is written and the
21
+ * result carries conflict: true — re-read, re-apply, retry.
22
+ */
23
+ ifMatch?: string | null
16
24
  }
17
25
 
18
- export interface PlatformStorageJsonWriteResult {
19
- path: string
20
- ok: true
21
- }
26
+ export type PlatformStorageJsonWriteResult =
27
+ | { path: string; ok: true; version: string }
28
+ | {
29
+ path: string
30
+ ok: false
31
+ conflict: true
32
+ value: unknown | null
33
+ version: string | null
34
+ }
22
35
 
23
36
  export declare function readPlatformStorageJson(
24
37
  request: PlatformStorageJsonRequest,
@@ -3,15 +3,18 @@ import { PLATFORM_BRIDGE_METHODS } from './methods.mjs'
3
3
 
4
4
  /**
5
5
  * @param {{ appSlug: string, fileName: string }} request
6
- * @returns {Promise<{ path: string, value: unknown | null }>}
6
+ * @returns {Promise<{ path: string, value: unknown | null, version: string | null }>}
7
7
  */
8
8
  export async function readPlatformStorageJson(request) {
9
9
  return bridge.call(PLATFORM_BRIDGE_METHODS.STORAGE_READ_JSON, [request])
10
10
  }
11
11
 
12
12
  /**
13
- * @param {{ appSlug: string, fileName: string, value: unknown }} request
14
- * @returns {Promise<{ path: string, ok: true }>}
13
+ * Optional `ifMatch` (version from readPlatformStorageJson) makes the write
14
+ * conditional: on conflict nothing is written and the result has
15
+ * `conflict: true` with the current value — re-read, re-apply, retry.
16
+ * @param {{ appSlug: string, fileName: string, value: unknown, ifMatch?: string | null }} request
17
+ * @returns {Promise<{ path: string, ok: boolean, version: string | null, conflict?: true, value?: unknown | null }>}
15
18
  */
16
19
  export async function writePlatformStorageJson(request) {
17
20
  return bridge.call(PLATFORM_BRIDGE_METHODS.STORAGE_WRITE_JSON, [request])
@@ -20,6 +20,7 @@ export interface AgentComposerProps {
20
20
  leading?: React.ReactNode
21
21
  followUps?: AgentUiFollowUp[]
22
22
  questionToolCalls?: AgentUiToolCall[]
23
+ questionPromptList?: React.ReactNode
23
24
  usage?: AgentUiRunUsage | null
24
25
  liveTurn?: AgentLiveTurn | null
25
26
  onChange: (value: string) => void
@@ -76,6 +77,7 @@ export function AgentComposer({
76
77
  leading,
77
78
  followUps = [],
78
79
  questionToolCalls = [],
80
+ questionPromptList = null,
79
81
  usage = null,
80
82
  liveTurn = null,
81
83
  onChange,
@@ -89,7 +91,7 @@ export function AgentComposer({
89
91
  const showUsage =
90
92
  liveTurn?.contextCompaction === 'summarizing' ||
91
93
  Boolean(usage && (usage.totalTokens > 0 || usage.contextFillTokens))
92
- const hasQuestions = questionToolCalls.length > 0
94
+ const hasQuestions = questionPromptList !== null || questionToolCalls.length > 0
93
95
 
94
96
  return (
95
97
  <StyledRoot>
@@ -105,11 +107,13 @@ export function AgentComposer({
105
107
  preserveChildrenOnExit
106
108
  aria-hidden={!hasQuestions}
107
109
  >
108
- <AgentQuestionPromptList
109
- toolCalls={questionToolCalls}
110
- onAnswer={onAnswerQuestionTool}
111
- onDecline={onDeclineQuestionTool}
112
- />
110
+ {questionPromptList ?? (
111
+ <AgentQuestionPromptList
112
+ toolCalls={questionToolCalls}
113
+ onAnswer={onAnswerQuestionTool}
114
+ onDecline={onDeclineQuestionTool}
115
+ />
116
+ )}
113
117
  </StyledQuestionPresence>
114
118
  <StyledComposerSurfaceAnchor>
115
119
  {showUsage ? (
@@ -12,7 +12,6 @@ import {
12
12
  import { AgentDrawerPanel } from './AgentDrawerPanel.js'
13
13
  import {
14
14
  AgentLivePhase,
15
- AgentUiRunState,
16
15
  type AgentDrawerPanelProps,
17
16
  } from './agentTypes.js'
18
17
 
@@ -27,12 +26,7 @@ function baseProps(
27
26
  messages: [],
28
27
  assistantLabel: 'Assistant',
29
28
  pendingToolCalls: [],
30
- runState: AgentUiRunState.AwaitingUser,
31
- busy: false,
32
29
  error: null,
33
- composerValue: 'draft',
34
- onComposerChange: vi.fn(),
35
- onSend: vi.fn(),
36
30
  onApproveTools: vi.fn(),
37
31
  onRejectTools: vi.fn(),
38
32
  ...overrides,
@@ -64,32 +58,10 @@ describe('AgentDrawerPanel composer and steering behavior', () => {
64
58
  })
65
59
  }
66
60
 
67
- it('keeps the composer textarea editable and replaces send with stop while busy', () => {
68
- render(baseProps({ busy: true, onStop: vi.fn() }))
69
-
70
- const textarea = host.querySelector('textarea')
71
- const stopButton = host.querySelector('button[aria-label="Stop"]')
72
- const sendButton = host.querySelector('button[aria-label="Send"]')
73
-
74
- expect(textarea).toBeInstanceOf(HTMLTextAreaElement)
75
- expect((textarea as HTMLTextAreaElement).disabled).toBe(false)
76
- expect(stopButton).toBeInstanceOf(HTMLButtonElement)
77
- expect((stopButton as HTMLButtonElement).disabled).toBe(false)
78
- expect(sendButton).toBeNull()
79
- })
80
-
81
- it('keeps the composer textarea editable when the run state is ready', () => {
82
- render(baseProps({ runState: AgentUiRunState.Ready }))
83
-
84
- const textarea = host.querySelector('textarea')
85
-
86
- expect(textarea).toBeInstanceOf(HTMLTextAreaElement)
87
- expect((textarea as HTMLTextAreaElement).disabled).toBe(false)
88
- })
89
-
90
- it('renders manual approval controls and composer together', () => {
61
+ it('renders manual approval controls above the composer slot', () => {
91
62
  render(
92
63
  baseProps({
64
+ composerSlot: <textarea aria-label="Composer" />,
93
65
  pendingToolCalls: [
94
66
  {
95
67
  id: 'call-1',
@@ -195,179 +167,4 @@ describe('AgentDrawerPanel composer and steering behavior', () => {
195
167
  expect(host.textContent).toContain('Prepared Summarize')
196
168
  })
197
169
 
198
- it('renders persisted follow-up controls above the composer surface', () => {
199
- const onRemoveFollowUp = vi.fn()
200
- const onSendFollowUpNow = vi.fn()
201
- render(
202
- baseProps({
203
- busy: true,
204
- followUps: [
205
- {
206
- id: 'followup-1',
207
- message: 'Look at the second chapter',
208
- position: 0,
209
- },
210
- ],
211
- onRemoveFollowUp,
212
- onSendFollowUpNow,
213
- }),
214
- )
215
-
216
- const sendNow = host.querySelector(
217
- 'button[aria-label="Send follow-up now"]',
218
- )
219
- const remove = host.querySelector('button[aria-label="Remove follow-up"]')
220
- const textarea = host.querySelector('textarea')
221
- const composerSurface = textarea?.parentElement
222
-
223
- expect(host.textContent).not.toContain('Queued follow-ups')
224
- expect(host.textContent).toContain('Look at the second chapter')
225
- expect(composerSurface?.textContent).not.toContain('Queued follow-ups')
226
- expect(composerSurface?.textContent).not.toContain(
227
- 'Look at the second chapter',
228
- )
229
- expect(sendNow).toBeInstanceOf(HTMLButtonElement)
230
- expect(remove).toBeInstanceOf(HTMLButtonElement)
231
-
232
- act(() => {
233
- sendNow?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
234
- remove?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
235
- })
236
-
237
- expect(onSendFollowUpNow).toHaveBeenCalledWith('followup-1')
238
- expect(onRemoveFollowUp).toHaveBeenCalledWith('followup-1')
239
- })
240
-
241
- it('keeps follow-up play available when the drawer is not busy', () => {
242
- const onSendFollowUpNow = vi.fn()
243
- render(
244
- baseProps({
245
- busy: false,
246
- followUps: [
247
- {
248
- id: 'followup-1',
249
- message: 'Run this next',
250
- position: 0,
251
- },
252
- ],
253
- onSendFollowUpNow,
254
- }),
255
- )
256
-
257
- const sendNow = host.querySelector(
258
- 'button[aria-label="Send follow-up now"]',
259
- )
260
-
261
- expect(sendNow).toBeInstanceOf(HTMLButtonElement)
262
- expect((sendNow as HTMLButtonElement).disabled).toBe(false)
263
-
264
- act(() => {
265
- sendNow?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
266
- })
267
-
268
- expect(onSendFollowUpNow).toHaveBeenCalledWith('followup-1')
269
- })
270
-
271
- it('renders question prompts in the composer without approval controls', async () => {
272
- const onAnswerQuestionTool = vi.fn()
273
- const onDeclineQuestionTool = vi.fn()
274
- render(
275
- baseProps({
276
- busy: true,
277
- pendingQuestionToolCalls: [
278
- {
279
- id: 'question-1',
280
- name: 'harness.questions',
281
- arguments: {
282
- question: 'Which source should I use?',
283
- mode: 'single_choice',
284
- options: [
285
- { id: 'notes', label: 'Notes' },
286
- { id: 'files', label: 'Files' },
287
- ],
288
- },
289
- },
290
- ],
291
- onAnswerQuestionTool,
292
- onDeclineQuestionTool,
293
- }),
294
- )
295
-
296
- const buttons = Array.from(host.querySelectorAll('button'))
297
- const notesButton = buttons.find(button => button.textContent === 'Notes')
298
- const answerButton = buttons.find(button => button.textContent === 'Answer')
299
- const approveButton = buttons.find(
300
- button => button.textContent === 'Approve',
301
- )
302
-
303
- expect(host.textContent).toContain('Which source should I use?')
304
- expect(approveButton).toBeUndefined()
305
- expect(notesButton).toBeInstanceOf(HTMLButtonElement)
306
- expect(notesButton?.disabled).toBe(false)
307
- expect(answerButton).toBeInstanceOf(HTMLButtonElement)
308
- expect(answerButton?.disabled).toBe(true)
309
-
310
- act(() => {
311
- notesButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
312
- })
313
-
314
- expect(answerButton?.disabled).toBe(false)
315
-
316
- await act(async () => {
317
- answerButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
318
- await Promise.resolve()
319
- })
320
-
321
- expect(onAnswerQuestionTool).toHaveBeenCalledWith('question-1', ['notes'])
322
-
323
- const declineInput = host.querySelector(
324
- 'input[aria-label="Decline reason"]',
325
- ) as HTMLInputElement | null
326
- const declineButton = buttons.find(
327
- button => button.textContent === 'Decline',
328
- )
329
- const setter = Object.getOwnPropertyDescriptor(
330
- HTMLInputElement.prototype,
331
- 'value',
332
- )?.set
333
-
334
- await act(async () => {
335
- setter?.call(declineInput, 'Need more context')
336
- declineInput?.dispatchEvent(new InputEvent('input', { bubbles: true }))
337
- })
338
- await act(async () => {
339
- declineButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
340
- await Promise.resolve()
341
- })
342
-
343
- expect(onDeclineQuestionTool).toHaveBeenCalledWith(
344
- 'question-1',
345
- 'Need more context',
346
- )
347
- })
348
-
349
- it('collapses question prompts with preserved exiting content', () => {
350
- const question = {
351
- id: 'question-1',
352
- name: 'harness.questions',
353
- arguments: {
354
- question: 'Which source should I use?',
355
- mode: 'single_choice',
356
- options: [{ id: 'notes', label: 'Notes' }],
357
- },
358
- }
359
-
360
- render(baseProps({ pendingQuestionToolCalls: [question] }))
361
-
362
- const openPresence = host.querySelector('[data-animation-direction="y"]')
363
- expect(openPresence).toBeInstanceOf(HTMLElement)
364
- expect(host.textContent).toContain('Which source should I use?')
365
-
366
- render(baseProps({ pendingQuestionToolCalls: [] }))
367
-
368
- const closingPresence = host.querySelector('[data-animation-direction="y"]')
369
- expect(closingPresence).toBeInstanceOf(HTMLElement)
370
- expect(closingPresence?.getAttribute('data-open')).toBe('false')
371
- expect(host.textContent).toContain('Which source should I use?')
372
- })
373
170
  })
@@ -1,5 +1,4 @@
1
1
  import { InlineBanner } from '../common/feedback/InlineBanner.js'
2
- import { AgentComposer } from './AgentComposer.js'
3
2
  import { AgentMessageList } from './AgentMessageList.js'
4
3
  import { AgentToolPendingList } from './AgentToolPendingList.js'
5
4
  import type { AgentDrawerPanelProps } from './agentTypes.js'
@@ -8,50 +7,31 @@ import {
8
7
  StyledAgentDrawer,
9
8
  StyledAgentFooter,
10
9
  StyledAgentThreadHost,
11
- StyledComposerFooterSpacer,
12
- StyledComposerReserveFooter,
13
10
  } from './agentPanelStyles.js'
14
11
 
15
12
  export function AgentDrawerPanel({
16
13
  messages,
17
14
  assistantLabel,
18
15
  pendingToolCalls,
19
- pendingQuestionToolCalls = [],
20
- busy,
21
16
  error,
22
- composerValue,
23
- composerDisabled = false,
24
- composerInputDisabled = false,
25
- composerSendDisabled = false,
26
- composerLeading,
27
- followUps = [],
17
+ composerSlot = null,
28
18
  activityHint = null,
29
- onComposerChange,
30
- onSend,
31
- onStop,
32
- onRemoveFollowUp,
33
- onSendFollowUpNow,
34
19
  onApproveTools,
35
20
  onRejectTools,
36
21
  onApproveOne,
37
22
  onRejectOne,
38
- onApproveAlwaysForSession,
39
- onAnswerQuestionTool,
40
- onDeclineQuestionTool,
23
+ approveAlwaysScopeLabel,
24
+ onApproveAlwaysForScope,
41
25
  storageHint = null,
42
- usage = null,
43
- liveTurn = null,
26
+ storageHintAction = null,
27
+ onStorageHintDismiss,
44
28
  pureAssistantTabActive = false,
45
- drawerOpen = true,
46
- composerPortaled = false,
47
29
  threadHeader = null,
48
- threadHeaderHeight = 34,
30
+ bottomPanel = null,
49
31
  }: AgentDrawerPanelProps): React.ReactElement {
50
32
  const awaitingApproval = pendingToolCalls.length > 0
51
- const composerBlocked = composerDisabled
33
+ void pureAssistantTabActive
52
34
 
53
- const showFooterComposer = !composerPortaled
54
- const reserveComposerFooter = composerPortaled && drawerOpen
55
35
  const footerWork = (
56
36
  <>
57
37
  {awaitingApproval ? (
@@ -62,63 +42,42 @@ export function AgentDrawerPanel({
62
42
  onReject={onRejectTools}
63
43
  onApproveOne={onApproveOne}
64
44
  onRejectOne={onRejectOne}
65
- onApproveAlwaysForSession={onApproveAlwaysForSession}
45
+ approveAlwaysScopeLabel={approveAlwaysScopeLabel}
46
+ onApproveAlwaysForScope={onApproveAlwaysForScope}
66
47
  />
67
48
  ) : null}
68
49
  </>
69
50
  )
70
- const hasFooterWork = awaitingApproval
71
51
 
72
52
  return (
73
- <StyledAgentDrawer
74
- data-pure-assistant-tab-active={pureAssistantTabActive || undefined}
75
- >
53
+ <StyledAgentDrawer>
76
54
  <StyledAgentBody>
77
55
  {storageHint ? (
78
- <InlineBanner variant="warning">{storageHint}</InlineBanner>
56
+ <InlineBanner
57
+ variant="warning"
58
+ actions={storageHintAction}
59
+ onDismiss={onStorageHintDismiss}
60
+ >
61
+ {storageHint}
62
+ </InlineBanner>
79
63
  ) : null}
80
64
  {activityHint ? (
81
65
  <InlineBanner variant="warning">{activityHint}</InlineBanner>
82
66
  ) : null}
83
67
  {error ? <InlineBanner variant="danger">{error}</InlineBanner> : null}
68
+ {threadHeader}
84
69
  <StyledAgentThreadHost>
85
70
  <AgentMessageList
86
71
  messages={messages}
87
72
  assistantLabel={assistantLabel}
88
- threadHeader={threadHeader}
89
- threadHeaderHeight={threadHeaderHeight}
90
73
  />
91
74
  </StyledAgentThreadHost>
92
75
  </StyledAgentBody>
93
- {showFooterComposer ? (
94
- <StyledAgentFooter>
95
- {footerWork}
96
- <AgentComposer
97
- value={composerValue}
98
- busy={busy}
99
- disabled={composerBlocked}
100
- inputDisabled={composerInputDisabled}
101
- sendDisabled={composerSendDisabled}
102
- leading={composerLeading}
103
- followUps={followUps}
104
- questionToolCalls={pendingQuestionToolCalls}
105
- usage={usage}
106
- liveTurn={liveTurn}
107
- onChange={onComposerChange}
108
- onSend={onSend}
109
- onStop={onStop}
110
- onRemoveFollowUp={onRemoveFollowUp}
111
- onSendFollowUpNow={onSendFollowUpNow}
112
- onAnswerQuestionTool={onAnswerQuestionTool}
113
- onDeclineQuestionTool={onDeclineQuestionTool}
114
- />
115
- </StyledAgentFooter>
116
- ) : reserveComposerFooter || hasFooterWork ? (
117
- <StyledComposerReserveFooter aria-hidden={!hasFooterWork}>
118
- {footerWork}
119
- {reserveComposerFooter ? <StyledComposerFooterSpacer /> : null}
120
- </StyledComposerReserveFooter>
121
- ) : null}
76
+ {bottomPanel}
77
+ <StyledAgentFooter>
78
+ {footerWork}
79
+ {composerSlot}
80
+ </StyledAgentFooter>
122
81
  </StyledAgentDrawer>
123
82
  )
124
83
  }
@@ -8,16 +8,17 @@ import {
8
8
  } from '../common/chat/index.js'
9
9
  import { AgentThinkingBlock } from './AgentThinkingBlock.js'
10
10
  import {
11
- agentToolResultDetails,
12
- formatAgentToolLabel,
13
- formatAgentToolRowSummary,
14
- } from './agentToolDisplay.js'
15
- import {
16
- AgentLivePhase,
17
- type AgentDelegatedActivity,
18
- type AgentUiMessage,
19
- type AgentUiToolCall,
20
- } from './agentTypes.js'
11
+ agentToolResultDetails,
12
+ formatAgentToolLabel,
13
+ formatAgentToolRowSummary,
14
+ } from './agentToolDisplay.js'
15
+ import {
16
+ AgentLivePhase,
17
+ type AgentDelegatedActivity,
18
+ type AgentToolActivity,
19
+ type AgentUiMessage,
20
+ type AgentUiToolCall,
21
+ } from './agentTypes.js'
21
22
 
22
23
  export interface AgentMessageBubbleProps {
23
24
  message: AgentUiMessage
@@ -57,17 +58,17 @@ export function AgentMessageBubble({
57
58
  ) : null
58
59
  }
59
60
  />
60
- {message.toolCalls?.map(call => (
61
- <ChatToolRow
62
- key={call.id}
63
- icon={
64
- call.status === 'running' || call.delegatedActivity ? (
65
- <RunningIcon size={11} aria-hidden />
66
- ) : (
67
- <Wrench size={11} />
61
+ {message.toolCalls?.map(call => (
62
+ <ChatToolRow
63
+ key={call.id}
64
+ icon={
65
+ toolCallIsIncomplete(call) || call.delegatedActivity ? (
66
+ <RunningIcon size={11} aria-hidden />
67
+ ) : (
68
+ <Wrench size={11} />
68
69
  )
69
70
  }
70
- title={formatAgentToolLabel(call.name)}
71
+ title={formatToolRowTitle(call)}
71
72
  summary={formatToolRowSummary(call)}
72
73
  details={formatToolRowDetails(call)}
73
74
  />
@@ -97,21 +98,46 @@ export function AgentMessageBubble({
97
98
  }
98
99
  }
99
100
 
100
- function formatToolRowSummary(call: AgentUiToolCall): string {
101
- if (call.delegatedActivity) {
102
- return delegatedActivitySummary(call.delegatedActivity)
103
- }
101
+ function formatToolRowSummary(call: AgentUiToolCall): string {
102
+ if (call.serviceActivity) {
103
+ return toolActivitySummary(call.serviceActivity)
104
+ }
105
+ if (call.delegatedActivity) {
106
+ return delegatedActivitySummary(call.delegatedActivity)
107
+ }
104
108
  return formatAgentToolRowSummary(call)
105
- }
106
-
107
- function formatToolRowDetails(call: AgentUiToolCall): string[] | undefined {
108
- if (call.delegatedActivity) {
109
- return delegatedActivityDetails(call.delegatedActivity)
110
- }
111
- return call.result ? agentToolResultDetails(call.result) : undefined
112
- }
113
-
114
- function delegatedActivityStatus(activity: AgentDelegatedActivity): string {
109
+ }
110
+
111
+ function formatToolRowTitle(call: AgentUiToolCall): string {
112
+ if (call.name === 'harness.request_app_development_iteration') {
113
+ return call.arguments.mode === 'ask'
114
+ ? 'Asking app builder'
115
+ : 'Requesting app changes'
116
+ }
117
+ return formatAgentToolLabel(call.name)
118
+ }
119
+
120
+ function formatToolRowDetails(call: AgentUiToolCall): string[] | undefined {
121
+ if (call.serviceActivity) {
122
+ return call.serviceActivity.detail ? [call.serviceActivity.detail] : undefined
123
+ }
124
+ if (call.delegatedActivity) {
125
+ return delegatedActivityDetails(call.delegatedActivity)
126
+ }
127
+ return call.result ? agentToolResultDetails(call.result) : undefined
128
+ }
129
+
130
+ function toolCallIsIncomplete(call: AgentUiToolCall): boolean {
131
+ return call.status !== 'done' && call.result === undefined
132
+ }
133
+
134
+ function toolActivitySummary(activity: AgentToolActivity): string {
135
+ return activity.detail
136
+ ? `${activity.title}: ${activity.detail}`
137
+ : activity.title
138
+ }
139
+
140
+ function delegatedActivityStatus(activity: AgentDelegatedActivity): string {
115
141
  switch (activity.liveTurn.phase) {
116
142
  case AgentLivePhase.Thinking:
117
143
  return 'starting'
@@ -14,25 +14,19 @@ export interface AgentMessageListProps {
14
14
  messages: AgentUiMessage[]
15
15
  assistantLabel?: string
16
16
  emptyMessage?: string
17
- threadHeader?: React.ReactNode
18
- threadHeaderHeight?: number
17
+ threadFooter?: React.ReactNode
19
18
  }
20
19
 
21
20
  export const AgentMessageList = memo(function AgentMessageList({
22
21
  messages,
23
22
  assistantLabel = 'assistant',
24
23
  emptyMessage = 'Ask the agent about your workspace. Tool calls wait for your approval.',
25
- threadHeader = null,
26
- threadHeaderHeight = 34,
24
+ threadFooter = null,
27
25
  }: AgentMessageListProps): React.ReactElement {
28
26
  const scrollKey = messages.map(message => message.id).join('|')
29
27
 
30
28
  return (
31
- <StyledAgentScroll
32
- data-thread-header={threadHeader ? '' : undefined}
33
- $threadHeaderHeight={threadHeaderHeight}
34
- >
35
- {threadHeader}
29
+ <StyledAgentScroll>
36
30
  <ChatThread
37
31
  scrollKey={scrollKey}
38
32
  emptyMessage={
@@ -40,6 +34,7 @@ export const AgentMessageList = memo(function AgentMessageList({
40
34
  <ChatThreadEmpty>{emptyMessage}</ChatThreadEmpty>
41
35
  ) : null
42
36
  }
37
+ footer={threadFooter}
43
38
  >
44
39
  {messages.map(message => (
45
40
  <Fragment key={message.id}>