@puredesktop/puredesktop-ui-bridge 2.1.7 → 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 (119) hide show
  1. package/package.json +89 -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 +57 -3
  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/collectionImagePaste.ts +19 -1
  14. package/src/bridge/context.mjs +32 -0
  15. package/src/bridge/credentials.d.mts +62 -0
  16. package/src/bridge/credentials.mjs +53 -0
  17. package/src/bridge/documents.d.mts +76 -0
  18. package/src/bridge/documents.mjs +40 -0
  19. package/src/bridge/events.d.mts +1 -0
  20. package/src/bridge/events.mjs +1 -0
  21. package/src/bridge/googleAuth.ts +7 -152
  22. package/src/bridge/mcpClient.d.mts +74 -0
  23. package/src/bridge/mcpClient.mjs +79 -0
  24. package/src/bridge/methods.d.mts +45 -0
  25. package/src/bridge/methods.mjs +46 -0
  26. package/src/bridge/notifications.d.mts +12 -0
  27. package/src/bridge/notifications.mjs +36 -0
  28. package/src/bridge/operations.d.mts +44 -0
  29. package/src/bridge/operations.mjs +40 -0
  30. package/src/bridge/pureRender/base.css +44 -0
  31. package/src/bridge/pureRender/document.ts +29 -2
  32. package/src/bridge/pureRender/index.ts +3 -0
  33. package/src/bridge/pureRender/normalize.ts +167 -0
  34. package/src/bridge/pureRender/sanitizeExportBody.test.ts +51 -0
  35. package/src/bridge/pureRender/sanitizeExportBody.ts +61 -0
  36. package/src/bridge/pureRender/styles.ts +125 -5
  37. package/src/bridge/pureRender/theme.test.ts +259 -0
  38. package/src/bridge/pureRender/theme.ts +19 -1
  39. package/src/bridge/pureRender/types.ts +33 -0
  40. package/src/bridge/pureRender/visualLayoutInspection.test.ts +26 -0
  41. package/src/bridge/pureRender/visualLayoutInspection.ts +75 -0
  42. package/src/bridge/react/useDocumentHotkeys.ts +41 -0
  43. package/src/bridge/react/useDocumentLifecycle.tsx +359 -0
  44. package/src/bridge/react/usePlatformAgentSessionDrive.test.tsx +29 -10
  45. package/src/bridge/react/usePlatformAgentSessionDrive.tsx +10 -81
  46. package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -0
  47. package/src/bridge/react/usePlatformAppSettings.tsx +104 -0
  48. package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -0
  49. package/src/bridge/react/usePlatformJsonStore.tsx +149 -0
  50. package/src/bridge/react/usePlatformOperations.tsx +75 -0
  51. package/src/bridge/storage.d.mts +23 -10
  52. package/src/bridge/storage.mjs +6 -3
  53. package/src/bridge/storage.test.ts +6 -6
  54. package/src/bridge/types.ts +7 -0
  55. package/src/components/agents/AgentComposer.tsx +10 -6
  56. package/src/components/agents/AgentDrawerPanel.test.tsx +2 -205
  57. package/src/components/agents/AgentDrawerPanel.tsx +23 -62
  58. package/src/components/agents/AgentMessageBubble.tsx +59 -33
  59. package/src/components/agents/AgentMessageList.tsx +4 -4
  60. package/src/components/agents/AgentQuestionPromptList.tsx +34 -212
  61. package/src/components/agents/AgentToolApprovalActions.tsx +13 -11
  62. package/src/components/agents/AgentToolCallCard.tsx +20 -20
  63. package/src/components/agents/AgentToolPendingList.tsx +6 -6
  64. package/src/components/agents/AgentToolPendingReview.tsx +6 -6
  65. package/src/components/agents/ContextPicker.tsx +239 -0
  66. package/src/components/agents/QuestionRequestList.tsx +278 -0
  67. package/src/components/agents/agentPanelStyles.ts +8 -31
  68. package/src/components/agents/agentTypes.ts +25 -41
  69. package/src/components/agents/index.ts +4 -0
  70. package/src/components/assets/asset-library/sidebar/AssetLibraryList.tsx +10 -4
  71. package/src/components/chrome/WorkspaceTabStrip.tsx +22 -4
  72. package/src/components/chrome/workspaceTabTypes.ts +2 -0
  73. package/src/components/common/chat/ChatBox.tsx +9 -9
  74. package/src/components/common/chat/ChatModelMenu.tsx +34 -4
  75. package/src/components/common/chat/ChatThread.tsx +23 -7
  76. package/src/components/common/connections/ProviderConnection.test.tsx +208 -0
  77. package/src/components/common/connections/ProviderConnection.tsx +277 -0
  78. package/src/components/common/connections/index.ts +5 -0
  79. package/src/components/common/containers/AppFrame.test.tsx +4 -4
  80. package/src/components/common/containers/AppFrame.tsx +26 -10
  81. package/src/components/common/containers/AppHeader.tsx +37 -15
  82. package/src/components/common/documents/DocumentHeaderActions.tsx +206 -0
  83. package/src/components/common/documents/DocumentSwitcher.tsx +1181 -0
  84. package/src/components/common/documents/index.ts +8 -0
  85. package/src/components/common/dropdown/MenuDropdownItem.tsx +14 -2
  86. package/src/components/common/dropdown/MenuPortal.tsx +3 -1
  87. package/src/components/common/dropdown/menuViewportPosition.test.ts +20 -10
  88. package/src/components/common/dropdown/menuViewportPosition.ts +35 -27
  89. package/src/components/common/overlays/Modal.tsx +13 -2
  90. package/src/components/common/research/EvidenceDossier.tsx +1232 -150
  91. package/src/components/common/research/index.ts +2 -0
  92. package/src/components/editor/EditorLinkPromptDialog.tsx +1 -1
  93. package/src/components/print-design/PrintDesignPanel.test.tsx +430 -40
  94. package/src/components/print-design/PrintDesignPanel.tsx +1770 -207
  95. package/src/components/print-design/index.ts +2 -0
  96. package/src/components/print-design/previewContent.test.ts +32 -0
  97. package/src/components/print-design/previewContent.ts +31 -0
  98. package/src/context/buildContextDocument.test.ts +240 -0
  99. package/src/context/buildContextDocument.ts +309 -0
  100. package/src/context/contextAccess.ts +66 -0
  101. package/src/context/contextConfig.ts +72 -0
  102. package/src/context/contextDocument.test.ts +155 -0
  103. package/src/context/contextDocument.ts +300 -0
  104. package/src/context/contextSections.test.ts +88 -0
  105. package/src/context/contextSections.ts +84 -0
  106. package/src/context/htmlToContextMarkdown.test.ts +134 -0
  107. package/src/context/htmlToContextMarkdown.ts +369 -0
  108. package/src/ics/generateIcs.test.ts +153 -0
  109. package/src/ics/generateIcs.ts +197 -0
  110. package/src/index.ts +3 -0
  111. package/src/net/httpRetry.test.ts +117 -0
  112. package/src/net/httpRetry.ts +111 -0
  113. package/src/theme/appAccents.test.ts +36 -35
  114. package/src/theme/appAccents.ts +93 -104
  115. package/src/theme/appIdentityCss.mjs +178 -229
  116. package/src/theme/appIdentityCss.ts +222 -231
  117. package/src/theme/themes/dark.ts +3 -1
  118. package/src/theme/themes/light.ts +3 -1
  119. package/src/bridge/googleProviderConfig.ts +0 -73
@@ -1,9 +1,8 @@
1
- import { useEffect, useMemo, useState } from 'react'
2
- import { styled } from 'styled-components'
3
- import { Button } from '../common/buttons/Button.js'
4
- import { FlexCol, FlexRow } from '../common/containers/flex.js'
5
- import { Text } from '../common/typography/Text.js'
6
1
  import type { AgentUiToolCall } from './agentTypes.js'
2
+ import {
3
+ QuestionRequestList,
4
+ type QuestionRequestView,
5
+ } from './QuestionRequestList.js'
7
6
 
8
7
  type QuestionMode = 'single_choice' | 'multiple_choice'
9
8
 
@@ -31,85 +30,6 @@ export interface AgentQuestionPromptListProps {
31
30
  ) => void | Promise<void>
32
31
  }
33
32
 
34
- const StyledList = styled(FlexCol)`
35
- gap: 6px;
36
- `
37
-
38
- const StyledPrompt = styled(FlexCol)`
39
- gap: var(--platform-spacing-sm);
40
- padding: 10px;
41
- border: 1px solid var(--platform-colors-border);
42
- border-radius: var(--platform-radius-sm);
43
- background: var(--platform-colors-bg);
44
- `
45
-
46
- const StyledQuestion = styled(Text)`
47
- line-height: 1.35;
48
- `
49
-
50
- const StyledOptionGrid = styled.div`
51
- display: grid;
52
- gap: 6px;
53
- `
54
-
55
- const StyledOptionButton = styled.button<{ $selected: boolean }>`
56
- display: grid;
57
- gap: 2px;
58
- width: 100%;
59
- padding: 8px 10px;
60
- border: 1px solid
61
- ${({ $selected }) =>
62
- $selected
63
- ? 'var(--platform-colors-accent)'
64
- : 'var(--platform-colors-border)'};
65
- border-radius: var(--platform-radius-sm);
66
- background: ${({ $selected }) =>
67
- $selected
68
- ? 'color-mix(in srgb, var(--platform-colors-accent) 10%, var(--platform-colors-bg))'
69
- : 'var(--platform-colors-surface)'};
70
- color: var(--platform-colors-text);
71
- text-align: left;
72
- cursor: pointer;
73
- transition: background var(--platform-transition-fast),
74
- border-color var(--platform-transition-fast);
75
-
76
- &:hover:not(:disabled) {
77
- background: var(--platform-colors-surface-hover);
78
- }
79
-
80
- &:focus {
81
- outline: none;
82
- }
83
-
84
- &:focus-visible {
85
- outline: 1px solid var(--platform-colors-border-strong);
86
- outline-offset: 2px;
87
- }
88
-
89
- &:disabled {
90
- opacity: 0.55;
91
- cursor: not-allowed;
92
- }
93
- `
94
-
95
- const StyledDeclineInput = styled.input`
96
- min-width: 0;
97
- flex: 1 1 auto;
98
- height: 28px;
99
- padding: 0 9px;
100
- border: 1px solid var(--platform-colors-border);
101
- border-radius: var(--platform-radius-sm);
102
- background: var(--platform-colors-surface);
103
- color: var(--platform-colors-text);
104
- font: inherit;
105
- font-size: var(--platform-typography-font-size-sm);
106
-
107
- &:focus {
108
- outline: none;
109
- border-color: var(--platform-colors-accent);
110
- }
111
- `
112
-
113
33
  function stringValue(value: unknown): string | null {
114
34
  return typeof value === 'string' && value.trim() ? value.trim() : null
115
35
  }
@@ -140,126 +60,28 @@ function parseQuestionPrompt(
140
60
  return { question, mode, options }
141
61
  }
142
62
 
143
- function AgentQuestionPrompt({
144
- toolCall,
145
- onAnswer,
146
- onDecline,
147
- }: {
148
- toolCall: AgentUiToolCall
149
- onAnswer?: (
150
- toolCallId: string,
151
- selectedOptionIds: readonly string[],
152
- ) => void | Promise<void>
153
- onDecline?: (
154
- toolCallId: string,
155
- declineReason: string,
156
- ) => void | Promise<void>
157
- }): React.ReactElement | null {
158
- const prompt = useMemo(() => parseQuestionPrompt(toolCall), [toolCall])
159
- const [selectedIds, setSelectedIds] = useState<string[]>([])
160
- const [declineReason, setDeclineReason] = useState('')
161
- const [submitting, setSubmitting] = useState(false)
162
-
163
- useEffect(() => {
164
- setSelectedIds([])
165
- setDeclineReason('')
166
- setSubmitting(false)
167
- }, [toolCall.id])
168
-
63
+ function requestFromToolCall(
64
+ toolCall: AgentUiToolCall,
65
+ ): QuestionRequestView | null {
66
+ const prompt = parseQuestionPrompt(toolCall)
169
67
  if (!prompt) return null
170
68
 
171
- const toggleOption = (optionId: string): void => {
172
- setSelectedIds(current => {
173
- if (prompt.mode === 'single_choice') return [optionId]
174
- return current.includes(optionId)
175
- ? current.filter(id => id !== optionId)
176
- : [...current, optionId]
177
- })
69
+ return {
70
+ id: toolCall.id,
71
+ questions: [
72
+ {
73
+ id: toolCall.id,
74
+ multiple: prompt.mode === 'multiple_choice',
75
+ options: prompt.options.map(option => ({
76
+ value: option.id,
77
+ label: option.label,
78
+ ...(option.description ? { description: option.description } : {}),
79
+ })),
80
+ question: prompt.question,
81
+ },
82
+ ],
83
+ rejectRequiresReason: true,
178
84
  }
179
-
180
- const submitAnswer = async (): Promise<void> => {
181
- if (!onAnswer || selectedIds.length === 0 || submitting) return
182
- setSubmitting(true)
183
- try {
184
- await onAnswer(toolCall.id, selectedIds)
185
- } finally {
186
- setSubmitting(false)
187
- }
188
- }
189
-
190
- const submitDecline = async (): Promise<void> => {
191
- const trimmedReason = declineReason.trim()
192
- if (!onDecline || !trimmedReason || submitting) return
193
- setSubmitting(true)
194
- try {
195
- await onDecline(toolCall.id, trimmedReason)
196
- } finally {
197
- setSubmitting(false)
198
- }
199
- }
200
-
201
- const canAnswer = selectedIds.length > 0 && Boolean(onAnswer) && !submitting
202
- const canDecline =
203
- declineReason.trim().length > 0 && Boolean(onDecline) && !submitting
204
-
205
- return (
206
- <StyledPrompt>
207
- <StyledQuestion size="sm" weight="medium">
208
- {prompt.question}
209
- </StyledQuestion>
210
- <StyledOptionGrid>
211
- {prompt.options.map(option => {
212
- const selected = selectedIds.includes(option.id)
213
- return (
214
- <StyledOptionButton
215
- key={option.id}
216
- type="button"
217
- disabled={submitting}
218
- $selected={selected}
219
- aria-pressed={selected}
220
- onClick={() => toggleOption(option.id)}
221
- >
222
- <Text size="sm" weight="medium">
223
- {option.label}
224
- </Text>
225
- {option.description ? (
226
- <Text size="sm" tone="secondary">
227
- {option.description}
228
- </Text>
229
- ) : null}
230
- </StyledOptionButton>
231
- )
232
- })}
233
- </StyledOptionGrid>
234
- <FlexRow $align="center" $justify="between" $gap="sm">
235
- <StyledDeclineInput
236
- value={declineReason}
237
- disabled={submitting}
238
- placeholder="Decline reason"
239
- aria-label="Decline reason"
240
- onChange={event => setDeclineReason(event.target.value)}
241
- />
242
- <Button
243
- size="sm"
244
- variant="subtle"
245
- disabled={!canDecline}
246
- loading={submitting}
247
- onClick={() => void submitDecline()}
248
- >
249
- Decline
250
- </Button>
251
- <Button
252
- size="sm"
253
- variant="primary"
254
- disabled={!canAnswer}
255
- loading={submitting}
256
- onClick={() => void submitAnswer()}
257
- >
258
- Answer
259
- </Button>
260
- </FlexRow>
261
- </StyledPrompt>
262
- )
263
85
  }
264
86
 
265
87
  export function AgentQuestionPromptList({
@@ -267,18 +89,18 @@ export function AgentQuestionPromptList({
267
89
  onAnswer,
268
90
  onDecline,
269
91
  }: AgentQuestionPromptListProps): React.ReactElement | null {
270
- if (toolCalls.length === 0) return null
92
+ const requests = toolCalls.flatMap((toolCall): QuestionRequestView[] => {
93
+ const request = requestFromToolCall(toolCall)
94
+ return request ? [request] : []
95
+ })
271
96
 
272
97
  return (
273
- <StyledList aria-label="Questions from agent">
274
- {toolCalls.map(toolCall => (
275
- <AgentQuestionPrompt
276
- key={toolCall.id}
277
- toolCall={toolCall}
278
- onAnswer={onAnswer}
279
- onDecline={onDecline}
280
- />
281
- ))}
282
- </StyledList>
98
+ <QuestionRequestList
99
+ requests={requests}
100
+ onAnswer={(requestId, answers) => onAnswer?.(requestId, answers[0] ?? [])}
101
+ onReject={(requestId, reason) =>
102
+ reason ? onDecline?.(requestId, reason) : undefined
103
+ }
104
+ />
283
105
  )
284
106
  }
@@ -3,17 +3,15 @@ import { ChatModelMenu } from '../common/chat/ChatModelMenu.js'
3
3
  import type { AgentUiToolCall } from './agentTypes.js'
4
4
  import { StyledToolStageActions } from './agentPanelStyles.js'
5
5
 
6
- const APPROVE_ALWAYS_SESSION = 'approve-always-session'
6
+ const APPROVE_ALWAYS_SCOPE = 'approve-always-scope'
7
7
 
8
8
  export interface AgentToolApprovalActionsProps {
9
9
  toolCall: AgentUiToolCall
10
10
  busy?: boolean
11
11
  onApproveThisTime: (toolCallId: string) => void
12
12
  onRejectThisTime: (toolCallId: string) => void
13
- onApproveAlwaysForSession?: (
14
- qualifiedName: string,
15
- toolCallId: string,
16
- ) => void
13
+ approveAlwaysScopeLabel?: string
14
+ onApproveAlwaysForScope?: (qualifiedName: string, toolCallId: string) => void
17
15
  }
18
16
 
19
17
  export function AgentToolApprovalActions({
@@ -21,25 +19,29 @@ export function AgentToolApprovalActions({
21
19
  busy = false,
22
20
  onApproveThisTime,
23
21
  onRejectThisTime,
24
- onApproveAlwaysForSession,
22
+ approveAlwaysScopeLabel,
23
+ onApproveAlwaysForScope,
25
24
  }: AgentToolApprovalActionsProps): React.ReactElement {
25
+ const showScopeOption = Boolean(
26
+ onApproveAlwaysForScope && approveAlwaysScopeLabel,
27
+ )
26
28
  return (
27
29
  <StyledToolStageActions>
28
- {onApproveAlwaysForSession ? (
30
+ {showScopeOption ? (
29
31
  <ChatModelMenu
30
32
  primaryActionLabel="Approve"
31
33
  onPrimaryAction={() => onApproveThisTime(toolCall.id)}
32
- value={APPROVE_ALWAYS_SESSION}
34
+ value={APPROVE_ALWAYS_SCOPE}
33
35
  options={[
34
36
  {
35
- value: APPROVE_ALWAYS_SESSION,
36
- label: 'Always approve for this session',
37
+ value: APPROVE_ALWAYS_SCOPE,
38
+ label: approveAlwaysScopeLabel!,
37
39
  },
38
40
  ]}
39
41
  disabled={busy}
40
42
  menuPlacement="above"
41
43
  aria-label="Approve"
42
- onSelect={() => onApproveAlwaysForSession(toolCall.name, toolCall.id)}
44
+ onSelect={() => onApproveAlwaysForScope!(toolCall.name, toolCall.id)}
43
45
  />
44
46
  ) : (
45
47
  <Button
@@ -1,31 +1,31 @@
1
- import { Text } from '../common/typography/Text.js'
2
- import type { AgentUiToolCall } from './agentTypes.js'
1
+ import { Text } from '../common/typography/Text.js'
2
+ import type { AgentUiToolCall } from './agentTypes.js'
3
3
  import {
4
4
  formatAgentToolLabel,
5
5
  formatAgentToolSummary,
6
6
  } from './agentToolDisplay.js'
7
- import { formatToolCallArguments } from './formatToolCallArguments.js'
8
- import { StyledToolArgs, StyledToolCard } from './agentPanelStyles.js'
7
+ import { formatToolCallArguments } from './formatToolCallArguments.js'
8
+ import { StyledToolArgs, StyledToolCard } from './agentPanelStyles.js'
9
9
 
10
- export interface AgentToolCallCardProps {
11
- toolCall: AgentUiToolCall
12
- footer?: React.ReactNode
13
- }
14
-
15
- export function AgentToolCallCard({
16
- toolCall,
17
- footer,
10
+ export interface AgentToolCallCardProps {
11
+ toolCall: AgentUiToolCall
12
+ footer?: React.ReactNode
13
+ }
14
+
15
+ export function AgentToolCallCard({
16
+ toolCall,
17
+ footer,
18
18
  }: AgentToolCallCardProps): React.ReactElement {
19
19
  return (
20
20
  <StyledToolCard>
21
21
  <Text weight="medium" size="sm">
22
22
  {formatAgentToolLabel(toolCall.name)}
23
23
  </Text>
24
- <StyledToolArgs>
25
- {formatAgentToolSummary(toolCall.name, toolCall.arguments) ||
26
- formatToolCallArguments(toolCall.arguments)}
27
- </StyledToolArgs>
28
- {footer}
29
- </StyledToolCard>
30
- )
31
- }
24
+ <StyledToolArgs>
25
+ {formatAgentToolSummary(toolCall.name, toolCall.arguments) ||
26
+ formatToolCallArguments(toolCall.arguments)}
27
+ </StyledToolArgs>
28
+ {footer}
29
+ </StyledToolCard>
30
+ )
31
+ }
@@ -8,10 +8,8 @@ export interface AgentToolPendingListProps {
8
8
  onReject: () => void
9
9
  onApproveOne?: (toolCallId: string) => void
10
10
  onRejectOne?: (toolCallId: string) => void
11
- onApproveAlwaysForSession?: (
12
- qualifiedName: string,
13
- toolCallId: string,
14
- ) => void
11
+ approveAlwaysScopeLabel?: string
12
+ onApproveAlwaysForScope?: (qualifiedName: string, toolCallId: string) => void
15
13
  }
16
14
 
17
15
  export function AgentToolPendingList({
@@ -21,7 +19,8 @@ export function AgentToolPendingList({
21
19
  onReject,
22
20
  onApproveOne,
23
21
  onRejectOne,
24
- onApproveAlwaysForSession,
22
+ approveAlwaysScopeLabel,
23
+ onApproveAlwaysForScope,
25
24
  }: AgentToolPendingListProps): React.ReactElement | null {
26
25
  if (toolCalls.length === 0) return null
27
26
 
@@ -33,7 +32,8 @@ export function AgentToolPendingList({
33
32
  onRejectAll={onReject}
34
33
  onApproveOne={onApproveOne ?? onApprove}
35
34
  onRejectOne={onRejectOne ?? onReject}
36
- onApproveAlwaysForSession={onApproveAlwaysForSession}
35
+ approveAlwaysScopeLabel={approveAlwaysScopeLabel}
36
+ onApproveAlwaysForScope={onApproveAlwaysForScope}
37
37
  />
38
38
  )
39
39
  }
@@ -16,10 +16,8 @@ export interface AgentToolPendingReviewProps {
16
16
  onRejectAll: () => void
17
17
  onApproveOne: (toolCallId: string) => void
18
18
  onRejectOne: (toolCallId: string) => void
19
- onApproveAlwaysForSession?: (
20
- qualifiedName: string,
21
- toolCallId: string,
22
- ) => void
19
+ approveAlwaysScopeLabel?: string
20
+ onApproveAlwaysForScope?: (qualifiedName: string, toolCallId: string) => void
23
21
  }
24
22
 
25
23
  export function AgentToolPendingReview({
@@ -29,7 +27,8 @@ export function AgentToolPendingReview({
29
27
  onRejectAll,
30
28
  onApproveOne,
31
29
  onRejectOne,
32
- onApproveAlwaysForSession,
30
+ approveAlwaysScopeLabel,
31
+ onApproveAlwaysForScope,
33
32
  }: AgentToolPendingReviewProps): React.ReactElement | null {
34
33
  const [index, setIndex] = useState(0)
35
34
  const count = toolCalls.length
@@ -88,7 +87,8 @@ export function AgentToolPendingReview({
88
87
  busy={busy}
89
88
  onApproveThisTime={onApproveOne}
90
89
  onRejectThisTime={onRejectOne}
91
- onApproveAlwaysForSession={onApproveAlwaysForSession}
90
+ approveAlwaysScopeLabel={approveAlwaysScopeLabel}
91
+ onApproveAlwaysForScope={onApproveAlwaysForScope}
92
92
  />
93
93
  }
94
94
  />
@@ -0,0 +1,239 @@
1
+ /**
2
+ * ContextPicker — Cmd-K-style overlay for choosing an object whose markdown
3
+ * context document should be attached as agent context.
4
+ *
5
+ * Generic on purpose: `search` and `onPick` are injected, so the shell
6
+ * drawer (IPC-backed) and plugin apps (bridge-backed `context.search`) use
7
+ * the same component — and agents use the same backing functions via the
8
+ * built-in `harness.context_search` / `harness.context_read` tools.
9
+ */
10
+
11
+ import { useCallback, useEffect, useRef, useState } from 'react'
12
+ import { styled } from 'styled-components'
13
+
14
+ import type { PlatformContextSearchHit } from '../../context/contextAccess.js'
15
+ import { SearchField } from '../common/inputs/SearchField.js'
16
+
17
+ export interface ContextPickerProps {
18
+ open: boolean
19
+ onClose: () => void
20
+ /** Search context-enabled objects — empty query lists everything. */
21
+ search: (query: string) => Promise<PlatformContextSearchHit[]>
22
+ /** Called with the chosen object; owner fetches markdown via context.read. */
23
+ onPick: (hit: PlatformContextSearchHit) => void | Promise<void>
24
+ title?: string
25
+ placeholder?: string
26
+ }
27
+
28
+ const Backdrop = styled.div`
29
+ position: fixed;
30
+ inset: 0;
31
+ z-index: 1000;
32
+ display: flex;
33
+ align-items: flex-start;
34
+ justify-content: center;
35
+ padding-top: 12vh;
36
+ background: color-mix(
37
+ in srgb,
38
+ var(--platform-color-background, #101014) 45%,
39
+ transparent
40
+ );
41
+ `
42
+
43
+ const Panel = styled.div`
44
+ width: min(560px, calc(100vw - 48px));
45
+ max-height: 60vh;
46
+ display: flex;
47
+ flex-direction: column;
48
+ gap: 8px;
49
+ padding: 12px;
50
+ border-radius: 12px;
51
+ border: 1px solid var(--platform-color-border, #2a2a32);
52
+ background: var(--platform-color-surface, #17171c);
53
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35);
54
+ `
55
+
56
+ const PanelTitle = styled.div`
57
+ font-size: 11px;
58
+ font-weight: 700;
59
+ text-transform: uppercase;
60
+ letter-spacing: 0.06em;
61
+ color: var(--platform-color-text-muted, #8b8b95);
62
+ padding: 2px 4px 0;
63
+ `
64
+
65
+ const Results = styled.ul`
66
+ list-style: none;
67
+ margin: 0;
68
+ padding: 0;
69
+ overflow-y: auto;
70
+ min-height: 0;
71
+ display: flex;
72
+ flex-direction: column;
73
+ gap: 2px;
74
+ `
75
+
76
+ const ResultRow = styled.li<{ $active: boolean }>`
77
+ display: flex;
78
+ align-items: center;
79
+ gap: 8px;
80
+ padding: 8px 10px;
81
+ border-radius: 8px;
82
+ cursor: pointer;
83
+ background: ${({ $active }) =>
84
+ $active
85
+ ? 'var(--platform-color-surface-raised, #22222a)'
86
+ : 'transparent'};
87
+ `
88
+
89
+ const ResultTitle = styled.span`
90
+ flex: 1;
91
+ min-width: 0;
92
+ overflow: hidden;
93
+ text-overflow: ellipsis;
94
+ white-space: nowrap;
95
+ font-size: 13px;
96
+ font-weight: 500;
97
+ color: var(--platform-color-text, #e8e8ee);
98
+ `
99
+
100
+ const KindBadge = styled.span`
101
+ flex: none;
102
+ font-size: 11px;
103
+ font-weight: 400;
104
+ padding: 1px 8px;
105
+ border-radius: 999px;
106
+ border: 1px solid var(--platform-color-border, #2a2a32);
107
+ color: var(--platform-color-text-muted, #8b8b95);
108
+ `
109
+
110
+ const EmptyNote = styled.div`
111
+ padding: 14px 10px;
112
+ font-size: 13px;
113
+ color: var(--platform-color-text-muted, #8b8b95);
114
+ `
115
+
116
+ export function ContextPicker({
117
+ open,
118
+ onClose,
119
+ search,
120
+ onPick,
121
+ title = 'Add context',
122
+ placeholder = 'Search manuscripts, books, documents…',
123
+ }: ContextPickerProps): React.ReactElement | null {
124
+ const [query, setQuery] = useState('')
125
+ const [hits, setHits] = useState<PlatformContextSearchHit[]>([])
126
+ const [activeIndex, setActiveIndex] = useState(0)
127
+ const [searching, setSearching] = useState(false)
128
+ const requestSeq = useRef(0)
129
+
130
+ const runSearch = useCallback(
131
+ (value: string) => {
132
+ const seq = ++requestSeq.current
133
+ setSearching(true)
134
+ void search(value)
135
+ .then(results => {
136
+ if (requestSeq.current !== seq) return
137
+ setHits(results)
138
+ setActiveIndex(0)
139
+ })
140
+ .catch(() => {
141
+ if (requestSeq.current !== seq) return
142
+ setHits([])
143
+ })
144
+ .finally(() => {
145
+ if (requestSeq.current === seq) setSearching(false)
146
+ })
147
+ },
148
+ [search],
149
+ )
150
+
151
+ useEffect(() => {
152
+ if (!open) return
153
+ setQuery('')
154
+ runSearch('')
155
+ }, [open, runSearch])
156
+
157
+ useEffect(() => {
158
+ if (!open) return
159
+ const timer = window.setTimeout(() => runSearch(query), 140)
160
+ return () => window.clearTimeout(timer)
161
+ }, [open, query, runSearch])
162
+
163
+ const pick = useCallback(
164
+ (hit: PlatformContextSearchHit | undefined) => {
165
+ if (!hit) return
166
+ void onPick(hit)
167
+ onClose()
168
+ },
169
+ [onClose, onPick],
170
+ )
171
+
172
+ const onKeyDown = useCallback(
173
+ (event: React.KeyboardEvent) => {
174
+ if (event.key === 'Escape') {
175
+ event.preventDefault()
176
+ onClose()
177
+ return
178
+ }
179
+ if (event.key === 'ArrowDown') {
180
+ event.preventDefault()
181
+ setActiveIndex(index => Math.min(index + 1, hits.length - 1))
182
+ return
183
+ }
184
+ if (event.key === 'ArrowUp') {
185
+ event.preventDefault()
186
+ setActiveIndex(index => Math.max(index - 1, 0))
187
+ return
188
+ }
189
+ if (event.key === 'Enter') {
190
+ event.preventDefault()
191
+ pick(hits[activeIndex])
192
+ }
193
+ },
194
+ [activeIndex, hits, onClose, pick],
195
+ )
196
+
197
+ if (!open) return null
198
+
199
+ return (
200
+ <Backdrop
201
+ onMouseDown={event => {
202
+ if (event.target === event.currentTarget) onClose()
203
+ }}
204
+ >
205
+ <Panel role="dialog" aria-label={title} onKeyDown={onKeyDown}>
206
+ <PanelTitle>{title}</PanelTitle>
207
+ <SearchField
208
+ value={query}
209
+ onValueChange={setQuery}
210
+ placeholder={placeholder}
211
+ autoFocus
212
+ />
213
+ <Results role="listbox">
214
+ {hits.map((hit, index) => (
215
+ <ResultRow
216
+ key={hit.packagePath}
217
+ role="option"
218
+ aria-selected={index === activeIndex}
219
+ $active={index === activeIndex}
220
+ onMouseEnter={() => setActiveIndex(index)}
221
+ onMouseDown={event => {
222
+ event.preventDefault()
223
+ pick(hit)
224
+ }}
225
+ >
226
+ <ResultTitle>{hit.title}</ResultTitle>
227
+ <KindBadge>{hit.kind}</KindBadge>
228
+ </ResultRow>
229
+ ))}
230
+ {hits.length === 0 ? (
231
+ <EmptyNote>
232
+ {searching ? 'Searching…' : 'No matching objects.'}
233
+ </EmptyNote>
234
+ ) : null}
235
+ </Results>
236
+ </Panel>
237
+ </Backdrop>
238
+ )
239
+ }