@puredesktop/puredesktop-ui-bridge 2.1.6 → 2.1.8

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 (64) hide show
  1. package/package.json +55 -1
  2. package/src/bridge/agentTypes.ts +21 -1
  3. package/src/bridge/collectionImagePaste.ts +19 -1
  4. package/src/bridge/context.mjs +32 -0
  5. package/src/bridge/documents.d.mts +76 -0
  6. package/src/bridge/documents.mjs +40 -0
  7. package/src/bridge/methods.d.mts +23 -0
  8. package/src/bridge/methods.mjs +23 -0
  9. package/src/bridge/pureRender/base.css +44 -0
  10. package/src/bridge/pureRender/document.ts +29 -2
  11. package/src/bridge/pureRender/index.ts +3 -0
  12. package/src/bridge/pureRender/normalize.ts +167 -0
  13. package/src/bridge/pureRender/sanitizeExportBody.test.ts +51 -0
  14. package/src/bridge/pureRender/sanitizeExportBody.ts +61 -0
  15. package/src/bridge/pureRender/styles.ts +125 -5
  16. package/src/bridge/pureRender/theme.test.ts +259 -0
  17. package/src/bridge/pureRender/theme.ts +19 -1
  18. package/src/bridge/pureRender/types.ts +33 -0
  19. package/src/bridge/pureRender/visualLayoutInspection.test.ts +26 -0
  20. package/src/bridge/pureRender/visualLayoutInspection.ts +75 -0
  21. package/src/bridge/react/useDocumentHotkeys.ts +41 -0
  22. package/src/bridge/react/useDocumentLifecycle.tsx +359 -0
  23. package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -0
  24. package/src/bridge/react/usePlatformAppSettings.tsx +104 -0
  25. package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -0
  26. package/src/bridge/react/usePlatformJsonStore.tsx +149 -0
  27. package/src/bridge/storage.d.mts +3 -1
  28. package/src/bridge/storage.test.ts +2 -2
  29. package/src/bridge/types.ts +7 -0
  30. package/src/components/agents/AgentDrawerPanel.tsx +2 -0
  31. package/src/components/agents/AgentMessageList.tsx +6 -1
  32. package/src/components/agents/AgentToolCallCard.tsx +20 -20
  33. package/src/components/agents/ContextPicker.tsx +239 -0
  34. package/src/components/agents/agentPanelStyles.ts +3 -4
  35. package/src/components/agents/agentTypes.ts +2 -0
  36. package/src/components/chrome/WorkspaceTabStrip.tsx +18 -1
  37. package/src/components/chrome/workspaceTabTypes.ts +2 -0
  38. package/src/components/common/containers/AppFrame.test.tsx +4 -4
  39. package/src/components/common/containers/AppFrame.tsx +10 -1
  40. package/src/components/common/containers/AppHeader.tsx +22 -0
  41. package/src/components/common/documents/DocumentHeaderActions.tsx +206 -0
  42. package/src/components/common/documents/DocumentSwitcher.tsx +1176 -0
  43. package/src/components/common/documents/index.ts +8 -0
  44. package/src/components/common/research/EvidenceDossier.tsx +1232 -150
  45. package/src/components/common/research/index.ts +2 -0
  46. package/src/components/print-design/PrintDesignPanel.test.tsx +430 -40
  47. package/src/components/print-design/PrintDesignPanel.tsx +1770 -207
  48. package/src/components/print-design/index.ts +2 -0
  49. package/src/components/print-design/previewContent.test.ts +32 -0
  50. package/src/components/print-design/previewContent.ts +31 -0
  51. package/src/context/buildContextDocument.test.ts +240 -0
  52. package/src/context/buildContextDocument.ts +309 -0
  53. package/src/context/contextAccess.ts +66 -0
  54. package/src/context/contextConfig.ts +72 -0
  55. package/src/context/contextDocument.test.ts +155 -0
  56. package/src/context/contextDocument.ts +300 -0
  57. package/src/context/contextSections.test.ts +88 -0
  58. package/src/context/contextSections.ts +84 -0
  59. package/src/context/htmlToContextMarkdown.test.ts +134 -0
  60. package/src/context/htmlToContextMarkdown.ts +369 -0
  61. package/src/theme/appAccents.test.ts +36 -35
  62. package/src/theme/appAccents.ts +87 -104
  63. package/src/theme/appIdentityCss.mjs +169 -231
  64. package/src/theme/appIdentityCss.ts +213 -233
@@ -0,0 +1,149 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import {
3
+ readPlatformStorageJson,
4
+ writePlatformStorageJson,
5
+ } from '../storage.mjs'
6
+
7
+ export type PlatformJsonStoreFileName = `${string}.json`
8
+
9
+ export interface UsePlatformJsonStoreOptions<TValue> {
10
+ appSlug: string | null
11
+ fileName: PlatformJsonStoreFileName | null
12
+ /** Default value when the JSON file does not exist yet. */
13
+ initialValue: TValue
14
+ /** Wait for bridge ready before loading (default: true when appSlug/fileName exist). */
15
+ enabled?: boolean
16
+ /** Normalize parsed JSON into the app-owned store shape. */
17
+ parse?: (value: unknown) => TValue
18
+ }
19
+
20
+ export interface UsePlatformJsonStoreResult<TValue> {
21
+ value: TValue
22
+ path: string | null
23
+ loading: boolean
24
+ saving: boolean
25
+ error: Error | null
26
+ saveValue: (next: TValue) => Promise<TValue>
27
+ updateValue: (updater: (current: TValue) => TValue) => Promise<TValue>
28
+ reload: () => void
29
+ }
30
+
31
+ function defaultParse<TValue>(value: unknown): TValue {
32
+ return value as TValue
33
+ }
34
+
35
+ /**
36
+ * Load and replace one shell-managed JSON store file through `storage.*`.
37
+ * Use this for app-owned internal records, not for user-visible documents.
38
+ */
39
+ export function usePlatformJsonStore<TValue>({
40
+ appSlug,
41
+ fileName,
42
+ initialValue,
43
+ enabled = Boolean(appSlug && fileName),
44
+ parse = defaultParse<TValue>,
45
+ }: UsePlatformJsonStoreOptions<TValue>): UsePlatformJsonStoreResult<TValue> {
46
+ const [value, setValue] = useState<TValue>(initialValue)
47
+ const [path, setPath] = useState<string | null>(null)
48
+ const [loading, setLoading] = useState(false)
49
+ const [saving, setSaving] = useState(false)
50
+ const [error, setError] = useState<Error | null>(null)
51
+ const [reloadToken, setReloadToken] = useState(0)
52
+ const valueRef = useRef(value)
53
+ const writeChainRef = useRef<Promise<TValue>>(Promise.resolve(value))
54
+ valueRef.current = value
55
+
56
+ const reload = useCallback(() => {
57
+ setReloadToken(token => token + 1)
58
+ }, [])
59
+
60
+ useEffect(() => {
61
+ if (!enabled || !appSlug || !fileName) return
62
+
63
+ let cancelled = false
64
+ setLoading(true)
65
+ setError(null)
66
+
67
+ void readPlatformStorageJson({ appSlug, fileName })
68
+ .then(result => {
69
+ if (cancelled) return
70
+ const next =
71
+ result.value === null ? initialValue : parse(result.value)
72
+ valueRef.current = next
73
+ setValue(next)
74
+ setPath(result.path)
75
+ })
76
+ .catch((cause: unknown) => {
77
+ if (!cancelled) {
78
+ setError(cause instanceof Error ? cause : new Error(String(cause)))
79
+ }
80
+ })
81
+ .finally(() => {
82
+ if (!cancelled) setLoading(false)
83
+ })
84
+
85
+ return () => {
86
+ cancelled = true
87
+ }
88
+ }, [appSlug, enabled, fileName, initialValue, parse, reloadToken])
89
+
90
+ const queueSave = useCallback(
91
+ (resolveNext: (current: TValue) => TValue): Promise<TValue> => {
92
+ if (!appSlug || !fileName) {
93
+ throw new Error('appSlug and fileName are required to save JSON storage')
94
+ }
95
+
96
+ const write = async (): Promise<TValue> => {
97
+ const next = resolveNext(valueRef.current)
98
+ setSaving(true)
99
+ try {
100
+ const result = await writePlatformStorageJson({
101
+ appSlug,
102
+ fileName,
103
+ value: next,
104
+ })
105
+ valueRef.current = next
106
+ setValue(next)
107
+ setPath(result.path)
108
+ setError(null)
109
+ return next
110
+ } catch (cause) {
111
+ const error =
112
+ cause instanceof Error ? cause : new Error(String(cause))
113
+ setError(error)
114
+ throw error
115
+ } finally {
116
+ setSaving(false)
117
+ }
118
+ }
119
+
120
+ const queued = writeChainRef.current.then(write, write)
121
+ writeChainRef.current = queued.catch(() => valueRef.current)
122
+ return queued
123
+ },
124
+ [appSlug, fileName],
125
+ )
126
+
127
+ const saveValue = useCallback(
128
+ (next: TValue): Promise<TValue> => queueSave(() => next),
129
+ [queueSave],
130
+ )
131
+
132
+ const updateValue = useCallback(
133
+ (updater: (current: TValue) => TValue): Promise<TValue> => {
134
+ return queueSave(updater)
135
+ },
136
+ [queueSave],
137
+ )
138
+
139
+ return {
140
+ value,
141
+ path,
142
+ loading,
143
+ saving,
144
+ error,
145
+ saveValue,
146
+ updateValue,
147
+ reload,
148
+ }
149
+ }
@@ -1,6 +1,8 @@
1
+ export type PlatformStorageJsonFileName = `${string}.json`
2
+
1
3
  export interface PlatformStorageJsonRequest {
2
4
  appSlug: string
3
- fileName: string
5
+ fileName: PlatformStorageJsonFileName
4
6
  }
5
7
 
6
8
  export interface PlatformStorageJsonReadResult {
@@ -20,7 +20,7 @@ beforeEach(() => {
20
20
 
21
21
  describe('storage bridge helpers', () => {
22
22
  it('reads JSON storage using the shell request object shape', async () => {
23
- const request = { appSlug: 'puretasks', fileName: 'tasks.json' }
23
+ const request = { appSlug: 'puretasks', fileName: 'tasks.json' } as const
24
24
  bridgeCall.mockResolvedValue({ path: '/tmp/tasks.json', value: null })
25
25
 
26
26
  await readPlatformStorageJson(request)
@@ -36,7 +36,7 @@ describe('storage bridge helpers', () => {
36
36
  appSlug: 'puretasks',
37
37
  fileName: 'tasks.json',
38
38
  value: { tasks: [] },
39
- }
39
+ } as const
40
40
  bridgeCall.mockResolvedValue({ path: '/tmp/tasks.json', ok: true })
41
41
 
42
42
  await writePlatformStorageJson(request)
@@ -2,6 +2,7 @@ export type PlatformBridgeEventName =
2
2
  import('./events.mjs').PlatformBridgeEventName
3
3
 
4
4
  import type { PlatformThemeMode } from '../theme/tokens.js'
5
+ import type { PlatformAppContextManifest } from '../context/contextConfig.js'
5
6
 
6
7
  /** Shell prefs exposed via `settings.prefs.get` / `settings.prefs.set`. */
7
8
  export type PlatformThemePreference = PlatformThemeMode
@@ -80,6 +81,8 @@ export interface PlatformAppManifest {
80
81
  productName: string
81
82
  kind: string
82
83
  description: string
84
+ /** Declarative context-file config for this app's package objects. */
85
+ context?: PlatformAppContextManifest
83
86
  peopleProviders?: Array<{
84
87
  id: string
85
88
  label: string
@@ -205,6 +208,10 @@ export interface RenderPagedSnapshotMetrics {
205
208
  captionVisible?: boolean
206
209
  captionOrphaned?: boolean
207
210
  strandedHeading?: boolean | null
211
+ /** Blocks on the target page that collide with each other. */
212
+ overlappingBlocks?: number
213
+ /** Blocks escaping the page content box. */
214
+ overflowingBlocks?: number
208
215
  }
209
216
 
210
217
  export interface RenderPagedPreviewResult {
@@ -45,6 +45,7 @@ export function AgentDrawerPanel({
45
45
  drawerOpen = true,
46
46
  composerPortaled = false,
47
47
  threadHeader = null,
48
+ threadHeaderHeight = 34,
48
49
  }: AgentDrawerPanelProps): React.ReactElement {
49
50
  const awaitingApproval = pendingToolCalls.length > 0
50
51
  const composerBlocked = composerDisabled
@@ -85,6 +86,7 @@ export function AgentDrawerPanel({
85
86
  messages={messages}
86
87
  assistantLabel={assistantLabel}
87
88
  threadHeader={threadHeader}
89
+ threadHeaderHeight={threadHeaderHeight}
88
90
  />
89
91
  </StyledAgentThreadHost>
90
92
  </StyledAgentBody>
@@ -15,6 +15,7 @@ export interface AgentMessageListProps {
15
15
  assistantLabel?: string
16
16
  emptyMessage?: string
17
17
  threadHeader?: React.ReactNode
18
+ threadHeaderHeight?: number
18
19
  }
19
20
 
20
21
  export const AgentMessageList = memo(function AgentMessageList({
@@ -22,11 +23,15 @@ export const AgentMessageList = memo(function AgentMessageList({
22
23
  assistantLabel = 'assistant',
23
24
  emptyMessage = 'Ask the agent about your workspace. Tool calls wait for your approval.',
24
25
  threadHeader = null,
26
+ threadHeaderHeight = 34,
25
27
  }: AgentMessageListProps): React.ReactElement {
26
28
  const scrollKey = messages.map(message => message.id).join('|')
27
29
 
28
30
  return (
29
- <StyledAgentScroll data-thread-header={threadHeader ? '' : undefined}>
31
+ <StyledAgentScroll
32
+ data-thread-header={threadHeader ? '' : undefined}
33
+ $threadHeaderHeight={threadHeaderHeight}
34
+ >
30
35
  {threadHeader}
31
36
  <ChatThread
32
37
  scrollKey={scrollKey}
@@ -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
+ }
@@ -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
+ }
@@ -23,17 +23,16 @@ export const StyledAgentThreadHost = styled.div`
23
23
  width: 100%;
24
24
  `
25
25
 
26
- export const StyledAgentScroll = styled.div`
26
+ export const StyledAgentScroll = styled.div<{ $threadHeaderHeight: number }>`
27
27
  position: relative;
28
- flex: 1 1 auto;
29
28
  min-height: 0;
30
- overflow: hidden;
31
29
  display: flex;
32
30
  flex-direction: column;
33
31
  --platform-agent-thread-header-height: 0px;
34
32
 
35
33
  &[data-thread-header] {
36
- --platform-agent-thread-header-height: 34px;
34
+ --platform-agent-thread-header-height: ${({ $threadHeaderHeight }) =>
35
+ $threadHeaderHeight}px;
37
36
  }
38
37
  `
39
38
 
@@ -136,4 +136,6 @@ export interface AgentDrawerPanelProps {
136
136
  composerPortaled?: boolean
137
137
  /** Shell thread chrome (scope, session actions) — anchored to the message column. */
138
138
  threadHeader?: React.ReactNode
139
+ /** Reserved top space for an absolute thread header. */
140
+ threadHeaderHeight?: number
139
141
  }
@@ -169,6 +169,20 @@ const StyledDocName = styled.span`
169
169
  color: var(--pure-chrome-tertiary, #8a857c);
170
170
  `
171
171
 
172
+ const StyledDevBadge = styled.span`
173
+ display: inline-flex;
174
+ align-items: center;
175
+ justify-content: center;
176
+ flex-shrink: 0;
177
+ font-size: 9px;
178
+ font-weight: 600;
179
+ line-height: 1;
180
+ letter-spacing: 0.04em;
181
+ text-transform: uppercase;
182
+ color: var(--pure-chrome-tertiary, #8a857c);
183
+ opacity: 0.72;
184
+ `
185
+
172
186
  const StyledClose = styled(X)`
173
187
  cursor: pointer;
174
188
  opacity: 0.7;
@@ -226,7 +240,7 @@ export function WorkspaceTabStrip({
226
240
  <StyledScroll role="tablist" aria-label={ariaLabel}>
227
241
  {tabs.map((tab, index) => {
228
242
  const active = tab.id === activeTabId
229
- const closable = !tab.pinned && onClose
243
+ const closable = onClose
230
244
  return (
231
245
  <StyledTab
232
246
  key={tab.id}
@@ -306,6 +320,9 @@ export function WorkspaceTabStrip({
306
320
  tab.label
307
321
  )}
308
322
  </StyledLabel>
323
+ {tab.underDevelopment ? (
324
+ <StyledDevBadge title="Under development">dev</StyledDevBadge>
325
+ ) : null}
309
326
  {closable ? (
310
327
  <StyledClose
311
328
  type="button"
@@ -15,6 +15,8 @@ export interface WorkspaceTabItem {
15
15
  letter: string
16
16
  label?: string
17
17
  }
18
+ /** True while the tab's app is owned by App Builder's dev lifecycle. */
19
+ underDevelopment?: boolean
18
20
  pinned?: boolean
19
21
  }
20
22
 
@@ -62,10 +62,10 @@ describe('AppFrame', () => {
62
62
 
63
63
  expect(document.getElementById(MENU_PORTAL_ID)).not.toBeNull()
64
64
  const baseStyles = document.getElementById(PLATFORM_APP_FRAME_BASE_STYLE_ID)
65
- expect(baseStyles).not.toBeNull()
66
- expect(baseStyles?.textContent).toContain('box-sizing: border-box')
67
- expect(baseStyles?.textContent).toContain('scrollbar-color')
68
- expect(document.getElementById(PLATFORM_FONT_STYLESHEET_ID)).not.toBeNull()
65
+ expect(baseStyles).not.toBeNull()
66
+ expect(baseStyles?.textContent).toContain('box-sizing: border-box')
67
+ expect(baseStyles?.textContent).toContain('scrollbar-color')
68
+ expect(document.getElementById(PLATFORM_FONT_STYLESHEET_ID)).not.toBeNull()
69
69
  expect(container.querySelector('[data-app="tasks"]')).not.toBeNull()
70
70
  expect(document.documentElement.dataset.app).toBe('tasks')
71
71
  expect(container.textContent).toContain('App content')
@@ -15,6 +15,8 @@ export interface AppFrameProps
15
15
  themeFromBridge?: boolean
16
16
  /** Optional actions rendered in the app header's action slot. */
17
17
  headerActions?: ReactNode
18
+ /** Open document's file name, shown beside the wordmark. */
19
+ headerDocumentName?: ReactNode
18
20
  }
19
21
 
20
22
  const StyledRoot = styled.div`
@@ -37,6 +39,7 @@ export function AppFrame({
37
39
  className,
38
40
  themeFromBridge = true,
39
41
  headerActions,
42
+ headerDocumentName,
40
43
  ...rest
41
44
  }: AppFrameProps): React.ReactElement {
42
45
  const { meta, ready } = usePlatformBridge()
@@ -73,7 +76,13 @@ export function AppFrame({
73
76
  data-platform-surface-palette={themeAttrs.surfacePalette || undefined}
74
77
  {...rest}
75
78
  >
76
- {manifest && <AppHeader manifest={manifest} actions={headerActions} />}
79
+ {manifest && (
80
+ <AppHeader
81
+ manifest={manifest}
82
+ actions={headerActions}
83
+ documentName={headerDocumentName}
84
+ />
85
+ )}
77
86
  {children}
78
87
  </StyledRoot>
79
88
  </>