@demicodes/web-ui 0.3.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 (114) hide show
  1. package/package.json +55 -0
  2. package/src/agent/AgentMessageInput.vue +164 -0
  3. package/src/agent/AgentMessageList.vue +124 -0
  4. package/src/agent/AgentPanel.vue +18 -0
  5. package/src/agent/AgentRoot.vue +17 -0
  6. package/src/agent/AgentTabBar.vue +422 -0
  7. package/src/agent/AgentTabItem.vue +150 -0
  8. package/src/agent/ContextUsageIndicator.vue +136 -0
  9. package/src/agent/ConversationListDropdown.vue +71 -0
  10. package/src/agent/ConversationStatusDot.vue +26 -0
  11. package/src/agent/ConversationView.vue +82 -0
  12. package/src/agent/MessageQueueBar.vue +67 -0
  13. package/src/agent/ModelSelector.vue +125 -0
  14. package/src/agent/ReasoningSelector.vue +106 -0
  15. package/src/agent/SelectorTrigger.vue +17 -0
  16. package/src/agent/__tests__/block-helpers.test.ts +73 -0
  17. package/src/agent/__tests__/input-actions.test.ts +130 -0
  18. package/src/agent/__tests__/input-editor.test.ts +15 -0
  19. package/src/agent/__tests__/pending-steers.test.ts +99 -0
  20. package/src/agent/__tests__/queue-submit.test.ts +14 -0
  21. package/src/agent/__tests__/reasoning.test.ts +32 -0
  22. package/src/agent/__tests__/tail-loading.test.ts +131 -0
  23. package/src/agent/__tests__/tool-rendering.test.ts +35 -0
  24. package/src/agent/__tests__/visible-blocks.test.ts +125 -0
  25. package/src/agent/block-helpers.ts +59 -0
  26. package/src/agent/block-types.ts +12 -0
  27. package/src/agent/blocks/AbortedBlock.vue +26 -0
  28. package/src/agent/blocks/AgentMessageVirtualBlock.vue +88 -0
  29. package/src/agent/blocks/AnsiText.vue +75 -0
  30. package/src/agent/blocks/AssistantTextBlock.vue +34 -0
  31. package/src/agent/blocks/CompactionBlock.vue +36 -0
  32. package/src/agent/blocks/ErrorBlock.vue +79 -0
  33. package/src/agent/blocks/FunctionalBlock.vue +118 -0
  34. package/src/agent/blocks/LoadingBlock.vue +28 -0
  35. package/src/agent/blocks/ThinkingBlock.vue +86 -0
  36. package/src/agent/blocks/ToolCallBlock.vue +40 -0
  37. package/src/agent/blocks/ToolGenericBlock.vue +43 -0
  38. package/src/agent/blocks/ToolShellAbortBlock.vue +13 -0
  39. package/src/agent/blocks/ToolShellBlock.vue +47 -0
  40. package/src/agent/blocks/ToolShellControlBlock.vue +51 -0
  41. package/src/agent/blocks/ToolShellStatusBlock.vue +13 -0
  42. package/src/agent/blocks/ToolShellWriteBlock.vue +13 -0
  43. package/src/agent/blocks/ToolStatusBadge.vue +13 -0
  44. package/src/agent/blocks/ToolYieldBlock.vue +13 -0
  45. package/src/agent/blocks/UserBlock.vue +112 -0
  46. package/src/agent/conversation-runtime.ts +205 -0
  47. package/src/agent/conversation-status.ts +12 -0
  48. package/src/agent/message-input/input-model.ts +33 -0
  49. package/src/agent/message-input/useAgentInputActions.ts +96 -0
  50. package/src/agent/message-input/useAgentInputEditor.ts +90 -0
  51. package/src/agent/message-input/useAgentInputSessionState.ts +48 -0
  52. package/src/agent/pending-steers.ts +134 -0
  53. package/src/agent/providers/ProviderIcon.vue +32 -0
  54. package/src/agent/providers/icons/claude.svg +1 -0
  55. package/src/agent/providers/icons/openai.svg +3 -0
  56. package/src/agent/queue-submit.ts +4 -0
  57. package/src/agent/reasoning.ts +45 -0
  58. package/src/agent/tail-loading.ts +31 -0
  59. package/src/agent/thinking-streaming.ts +7 -0
  60. package/src/agent/tool-rendering.ts +59 -0
  61. package/src/agent/types.ts +39 -0
  62. package/src/agent/ui-options.ts +21 -0
  63. package/src/agent/visible-blocks.ts +25 -0
  64. package/src/agent/workspace.ts +355 -0
  65. package/src/composables/useBlockVirtualizer.ts +290 -0
  66. package/src/composables/useContextMenuOwner.ts +44 -0
  67. package/src/composables/useOverlay.ts +18 -0
  68. package/src/composables/useTerminalTheme.ts +75 -0
  69. package/src/env.d.ts +10 -0
  70. package/src/infra/errors.ts +29 -0
  71. package/src/infra/i18n.ts +41 -0
  72. package/src/markdown/filePath.ts +54 -0
  73. package/src/markdown/highlight.ts +139 -0
  74. package/src/markdown/md.ts +58 -0
  75. package/src/markdown/render.ts +117 -0
  76. package/src/markdown/types.ts +20 -0
  77. package/src/overlay/appOverlay.ts +3 -0
  78. package/src/overlay/overlayStore.ts +54 -0
  79. package/src/store/createStore.ts +33 -0
  80. package/src/store/useStore.ts +7 -0
  81. package/src/styles/base.css +342 -0
  82. package/src/theme/appTheme.ts +56 -0
  83. package/src/theme/codeThemes.ts +454 -0
  84. package/src/theme/themeStore.ts +33 -0
  85. package/src/transport/agent-socket.ts +20 -0
  86. package/src/transport/control-client.ts +64 -0
  87. package/src/transport/protocol.ts +57 -0
  88. package/src/ui/Button.vue +30 -0
  89. package/src/ui/Checkbox.vue +25 -0
  90. package/src/ui/ContextMenu.vue +33 -0
  91. package/src/ui/Dialog.vue +48 -0
  92. package/src/ui/DropdownMenu.vue +77 -0
  93. package/src/ui/ErrorBox.vue +43 -0
  94. package/src/ui/HighlightText.vue +39 -0
  95. package/src/ui/IconButton.vue +30 -0
  96. package/src/ui/IndeterminateSpinner.vue +72 -0
  97. package/src/ui/InlineError.vue +25 -0
  98. package/src/ui/LoadMore.vue +32 -0
  99. package/src/ui/MarkdownPreview.vue +31 -0
  100. package/src/ui/Menu.vue +8 -0
  101. package/src/ui/MenuDivider.vue +6 -0
  102. package/src/ui/MenuItem.vue +48 -0
  103. package/src/ui/OptionMenu.vue +8 -0
  104. package/src/ui/OptionMenuGroup.vue +16 -0
  105. package/src/ui/OptionMenuItem.vue +28 -0
  106. package/src/ui/Popover.vue +90 -0
  107. package/src/ui/ScrollToBottomButton.vue +32 -0
  108. package/src/ui/SelectDropdown.vue +231 -0
  109. package/src/ui/Switch.vue +41 -0
  110. package/src/ui/TextInput.vue +44 -0
  111. package/src/ui/ThemeToggle.vue +49 -0
  112. package/src/ui/ToggleSwitch.vue +34 -0
  113. package/src/ui/Tooltip.vue +143 -0
  114. package/src/ui/blankClickGuard.ts +12 -0
@@ -0,0 +1,355 @@
1
+ import { computed, inject, provide, reactive, ref, type ComputedRef, type InjectionKey } from 'vue'
2
+ import type { UserContentBlock } from '@demicodes/core'
3
+ import type { ConversationSummary } from '@demicodes/agent'
4
+ import type { ControlApi, ModelInfo, ProviderInfo } from '../transport/protocol'
5
+ import { agentSocketUrl, connectAgentClient } from '../transport/agent-socket'
6
+ import { ConversationRuntime } from './conversation-runtime'
7
+ import type { ConversationState, ModelIntent } from './types'
8
+
9
+ export interface AgentWorkspaceParams {
10
+ baseUrl: string
11
+ control: ControlApi
12
+ cwd: string
13
+ idFactory?: () => string
14
+ }
15
+
16
+ interface PersistedWorkspace {
17
+ order: string[]
18
+ activeId: string | null
19
+ conversations: { id: string; title: string; createdAt: string; model: ModelIntent }[]
20
+ }
21
+
22
+ function workspaceStorage(): Storage | null {
23
+ try {
24
+ return typeof localStorage !== 'undefined' ? localStorage : null
25
+ } catch {
26
+ return null
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Client-side workspace store. Owns the open conversations (tabs), their
32
+ * reactive state, and the AgentClient runtimes. Replaces agent-gui's
33
+ * server-synced `rpc.agent`/`rpc.project` state.
34
+ */
35
+ export class AgentWorkspace {
36
+ readonly sessions = reactive<Record<string, ConversationState>>({})
37
+ readonly order = ref<string[]>([])
38
+ readonly activeId = ref<string | null>(null)
39
+ readonly providers = ref<ProviderInfo[]>([])
40
+ readonly models = reactive<Record<string, ModelInfo[]>>({})
41
+ // All persisted conversations for this cwd, from the server (the history list,
42
+ // independent of which tabs are open or of localStorage).
43
+ readonly serverConversations = ref<ConversationSummary[]>([])
44
+
45
+ readonly tabs: ComputedRef<ConversationState[]> = computed(() =>
46
+ this.order.value.map((id) => this.sessions[id]).filter((state): state is ConversationState => !!state),
47
+ )
48
+ readonly activeSession: ComputedRef<ConversationState | null> = computed(() =>
49
+ this.activeId.value ? this.sessions[this.activeId.value] ?? null : null,
50
+ )
51
+
52
+ private readonly runtimes = new Map<string, ConversationRuntime>()
53
+ private readonly baseUrl: string
54
+ private readonly control: ControlApi
55
+ private readonly cwd: string
56
+ private readonly idFactory: () => string
57
+ private defaultModel: ModelIntent | null = null
58
+ private titleCounter = 0
59
+
60
+ private readonly storageKey: string
61
+
62
+ constructor(params: AgentWorkspaceParams) {
63
+ this.baseUrl = params.baseUrl
64
+ this.control = params.control
65
+ this.cwd = params.cwd
66
+ this.idFactory = params.idFactory ?? (() => globalThis.crypto.randomUUID())
67
+ this.storageKey = `demi.conversations.${params.cwd}`
68
+ }
69
+
70
+ async init(): Promise<void> {
71
+ await this.loadCatalog()
72
+ // Restore previously open conversations (their transcripts come back from
73
+ // the server on connect, keyed by the conversation id). Fall back to a
74
+ // fresh one when nothing is persisted.
75
+ const restored = this.restorePersisted()
76
+ // The server owns the full conversation list (survives a cleared browser).
77
+ this.serverConversations.value = await this.loadServerConversations()
78
+ if (!restored) {
79
+ // Nothing remembered locally — recover the most recent conversation from
80
+ // the server, or start fresh if there is none.
81
+ const recent = this.serverConversations.value[0]
82
+ if (recent) this.openConversation(recent)
83
+ else this.createConversation()
84
+ }
85
+ // Connect the visible conversation so a restored transcript loads on open.
86
+ this.connectActive()
87
+ }
88
+
89
+ /** Re-fetch the server conversation list (e.g. when the history menu opens). */
90
+ async refreshServerConversations(): Promise<void> {
91
+ this.serverConversations.value = await this.loadServerConversations()
92
+ }
93
+
94
+ /** Open a conversation by id (an existing tab, or one from the history list). */
95
+ openConversation(summary: { id: string; title?: string }): void {
96
+ if (!this.sessions[summary.id]) {
97
+ this.materializeConversation({
98
+ id: summary.id,
99
+ title: summary.title || this.nextTitle(),
100
+ createdAt: new Date().toISOString(),
101
+ model: this.defaultModel ?? this.fallbackModel(),
102
+ })
103
+ this.persist()
104
+ }
105
+ this.setActive(summary.id)
106
+ this.connectActive()
107
+ }
108
+
109
+ private async loadServerConversations(): Promise<ConversationSummary[]> {
110
+ try {
111
+ const client = await connectAgentClient(agentSocketUrl(this.baseUrl, this.cwd))
112
+ try {
113
+ return await client.listConversations(this.cwd)
114
+ } finally {
115
+ await client.close().catch(() => {})
116
+ }
117
+ } catch {
118
+ return this.serverConversations.value
119
+ }
120
+ }
121
+
122
+ private connectActive(): void {
123
+ const id = this.activeId.value
124
+ if (id) void this.runtimes.get(id)?.connect().catch(() => {})
125
+ }
126
+
127
+ async loadCatalog(): Promise<void> {
128
+ const providers = await this.control.listProviders()
129
+ this.providers.value = providers
130
+ for (const provider of providers) {
131
+ if (!provider.isAvailable) continue
132
+ try {
133
+ this.models[provider.id] = await this.control.listModels({ providerId: provider.id })
134
+ } catch {
135
+ this.models[provider.id] = []
136
+ }
137
+ }
138
+ this.defaultModel = this.resolveDefaultModel()
139
+ }
140
+
141
+ createConversation(options: { afterId?: string; title?: string } = {}): string {
142
+ const id = this.materializeConversation(
143
+ {
144
+ id: this.idFactory(),
145
+ title: options.title ?? this.nextTitle(),
146
+ createdAt: new Date().toISOString(),
147
+ model: this.defaultModel ?? this.fallbackModel(),
148
+ },
149
+ options.afterId,
150
+ )
151
+ this.activeId.value = id
152
+ this.persist()
153
+ return id
154
+ }
155
+
156
+ // Create the reactive state + runtime for a conversation and slot it into the
157
+ // tab order. Shared by new conversations and persistence restore.
158
+ private materializeConversation(
159
+ meta: { id: string; title: string; createdAt: string; model: ModelIntent },
160
+ afterId?: string,
161
+ ): string {
162
+ const state = reactive<ConversationState>({
163
+ id: meta.id,
164
+ cwd: this.cwd,
165
+ title: meta.title,
166
+ createdAt: meta.createdAt,
167
+ blocks: [],
168
+ phase: 'idle',
169
+ queue: [],
170
+ pendingSteers: [],
171
+ model: meta.model,
172
+ draft: null,
173
+ isResultSeen: true,
174
+ hasContent: false,
175
+ lastError: null,
176
+ })
177
+ this.sessions[meta.id] = state
178
+ this.runtimes.set(meta.id, new ConversationRuntime(state, this.baseUrl, this.control))
179
+ const index = afterId ? this.order.value.indexOf(afterId) + 1 : this.order.value.length
180
+ this.order.value = [...this.order.value.slice(0, index), meta.id, ...this.order.value.slice(index)]
181
+ return meta.id
182
+ }
183
+
184
+ async closeConversation(id: string): Promise<void> {
185
+ this.order.value = this.order.value.filter((entry) => entry !== id)
186
+ delete this.sessions[id]
187
+ if (this.activeId.value === id) this.activeId.value = this.order.value[0] ?? null
188
+ const runtime = this.runtimes.get(id)
189
+ this.runtimes.delete(id)
190
+ this.persist()
191
+ await runtime?.dispose()
192
+ }
193
+
194
+ setActive(id: string): void {
195
+ if (!this.sessions[id]) return
196
+ this.activeId.value = id
197
+ const state = this.sessions[id]
198
+ if (state) state.isResultSeen = true
199
+ this.connectActive()
200
+ this.persist()
201
+ }
202
+
203
+ reorderTabs(ids: string[]): void {
204
+ this.order.value = ids
205
+ this.persist()
206
+ }
207
+
208
+ renameConversation(id: string, title: string): void {
209
+ const state = this.sessions[id]
210
+ if (state) state.title = title
211
+ this.persist()
212
+ }
213
+
214
+ setModel(id: string, model: ModelIntent): void {
215
+ const state = this.sessions[id]
216
+ if (state) state.model = model
217
+ // Push to an open session so the next turn uses the new model; no-op until it opens.
218
+ void this.runtimes.get(id)?.setModel()
219
+ this.persist()
220
+ }
221
+
222
+ send(id: string, content: UserContentBlock[]): Promise<void> {
223
+ return this.runtime(id).send(content)
224
+ }
225
+
226
+ dequeueMessage(id: string, messageId: string): void {
227
+ this.runtimes.get(id)?.dequeueMessage(messageId)
228
+ }
229
+
230
+ sendQueuedMessage(id: string, messageId: string): void {
231
+ this.runtimes.get(id)?.sendQueuedMessage(messageId)
232
+ }
233
+
234
+ steerQueuedMessage(id: string, messageId: string): Promise<void> {
235
+ return this.runtime(id).steerQueuedMessage(messageId)
236
+ }
237
+
238
+ clearMessageQueue(id: string): void {
239
+ const messageIds = this.sessions[id]?.queue.map((message) => message.id) ?? []
240
+ this.runtimes.get(id)?.clearMessageQueue(messageIds)
241
+ }
242
+
243
+ steer(id: string, content: UserContentBlock[]): Promise<void> {
244
+ return this.runtime(id).steer(content)
245
+ }
246
+
247
+ deletePendingSteer(id: string, steerId: string): void {
248
+ this.runtimes.get(id)?.deletePendingSteer(steerId)
249
+ }
250
+
251
+ abort(id: string): Promise<void> {
252
+ return this.runtime(id).abort()
253
+ }
254
+
255
+ retry(id: string): Promise<void> {
256
+ return this.runtime(id).retry()
257
+ }
258
+
259
+ resume(id: string): Promise<void> {
260
+ return this.runtime(id).resume()
261
+ }
262
+
263
+ compact(id: string): Promise<void> {
264
+ return this.runtime(id).compact()
265
+ }
266
+
267
+ async dispose(): Promise<void> {
268
+ const runtimes = [...this.runtimes.values()]
269
+ this.runtimes.clear()
270
+ await Promise.all(runtimes.map((runtime) => runtime.dispose()))
271
+ }
272
+
273
+ private runtime(id: string): ConversationRuntime {
274
+ const runtime = this.runtimes.get(id)
275
+ if (!runtime) throw new Error(`No conversation runtime for ${id}`)
276
+ return runtime
277
+ }
278
+
279
+ // Persist the conversation list (ids/titles/models/order) per cwd. Transcripts
280
+ // are not stored here — they are restored from the server by conversation id.
281
+ private persist(): void {
282
+ const storage = workspaceStorage()
283
+ if (!storage) return
284
+ const conversations = this.order.value
285
+ .map((id) => this.sessions[id])
286
+ .filter((state): state is ConversationState => !!state)
287
+ .map((state) => ({ id: state.id, title: state.title, createdAt: state.createdAt, model: state.model }))
288
+ const payload: PersistedWorkspace = { order: this.order.value, activeId: this.activeId.value, conversations }
289
+ try {
290
+ storage.setItem(this.storageKey, JSON.stringify(payload))
291
+ } catch {
292
+ // Storage full or unavailable — persistence is best-effort.
293
+ }
294
+ }
295
+
296
+ // Returns true if conversations were restored from storage.
297
+ private restorePersisted(): boolean {
298
+ const storage = workspaceStorage()
299
+ if (!storage) return false
300
+ let payload: PersistedWorkspace | null = null
301
+ try {
302
+ const raw = storage.getItem(this.storageKey)
303
+ payload = raw ? (JSON.parse(raw) as PersistedWorkspace) : null
304
+ } catch {
305
+ payload = null
306
+ }
307
+ const conversations = payload?.conversations ?? []
308
+ if (conversations.length === 0) return false
309
+ const byId = new Map(conversations.map((conversation) => [conversation.id, conversation]))
310
+ const order = (payload?.order ?? []).filter((id) => byId.has(id))
311
+ for (const id of order) {
312
+ const meta = byId.get(id)!
313
+ this.materializeConversation({ id: meta.id, title: meta.title, createdAt: meta.createdAt, model: meta.model })
314
+ }
315
+ this.titleCounter = order.length
316
+ this.activeId.value = payload?.activeId && byId.has(payload.activeId) ? payload.activeId : order[0] ?? null
317
+ return order.length > 0
318
+ }
319
+
320
+ private resolveDefaultModel(): ModelIntent | null {
321
+ for (const provider of this.providers.value) {
322
+ const model = this.models[provider.id]?.[0]
323
+ if (model) {
324
+ return {
325
+ providerId: provider.id,
326
+ modelId: model.id,
327
+ thinkingEffort: model.reasoning?.defaultEffort ?? null,
328
+ serviceTierId: null,
329
+ }
330
+ }
331
+ }
332
+ return null
333
+ }
334
+
335
+ private fallbackModel(): ModelIntent {
336
+ return { providerId: 'claude-code', modelId: 'default', thinkingEffort: null, serviceTierId: null }
337
+ }
338
+
339
+ private nextTitle(): string {
340
+ this.titleCounter += 1
341
+ return `Conversation ${this.titleCounter}`
342
+ }
343
+ }
344
+
345
+ const AGENT_WORKSPACE_KEY: InjectionKey<AgentWorkspace> = Symbol('demi.agent-workspace')
346
+
347
+ export function provideAgentWorkspace(workspace: AgentWorkspace): void {
348
+ provide(AGENT_WORKSPACE_KEY, workspace)
349
+ }
350
+
351
+ export function useAgentWorkspace(): AgentWorkspace {
352
+ const workspace = inject(AGENT_WORKSPACE_KEY)
353
+ if (!workspace) throw new Error('AgentWorkspace is not provided')
354
+ return workspace
355
+ }
@@ -0,0 +1,290 @@
1
+ import { computed, nextTick, ref, type Ref, watch } from 'vue'
2
+ import { useVirtualizer } from '@tanstack/vue-virtual'
3
+
4
+ type ScrollIntent = 'up' | 'down' | null
5
+ interface VirtualizedBlock {
6
+ id: string
7
+ type: string
8
+ }
9
+
10
+ const OVERSCAN = 8
11
+ const BLOCK_GAP = 4
12
+ const BOTTOM_THRESHOLD = 100
13
+ const AUTO_SCROLL_REENGAGE_THRESHOLD = 1
14
+
15
+ const BLOCK_HEIGHT_ESTIMATES: Record<string, number> = {
16
+ user: 40,
17
+ steer: 40,
18
+ pending_steer: 40,
19
+ resume: 0,
20
+ thinking: 20,
21
+ redacted_thinking: 0,
22
+ text: 40,
23
+ response: 24,
24
+ tool_call: 28,
25
+ error: 28,
26
+ abort: 0,
27
+ compaction_boundary: 36,
28
+ compaction_marker: 0,
29
+ extension_state_snapshot: 0,
30
+ }
31
+
32
+ export interface ScrollAnchor {
33
+ blockId: string
34
+ anchorIndex: number
35
+ offsetPx: number
36
+ scrollTop: number
37
+ }
38
+
39
+ export interface PersistedScrollState {
40
+ anchor: ScrollAnchor
41
+ heightCache: Map<string, number>
42
+ }
43
+
44
+ export function useBlockVirtualizer(
45
+ scrollContainer: Ref<HTMLDivElement | undefined>,
46
+ blocks: Ref<VirtualizedBlock[]>,
47
+ persistedState: PersistedScrollState | undefined,
48
+ ) {
49
+ const heightCache = new Map<string, number>(persistedState?.heightCache ?? [])
50
+
51
+ const scrollOffset = ref(0)
52
+ const isAtBottom = ref(true)
53
+ const isRestored = ref(false)
54
+
55
+ const virtualizer = useVirtualizer<HTMLDivElement, HTMLElement>(
56
+ computed(() => ({
57
+ count: blocks.value.length,
58
+ getScrollElement: () => scrollContainer.value ?? null,
59
+ estimateSize: (index: number) => {
60
+ const block = blocks.value[index]
61
+ if (!block) return 40
62
+ return heightCache.get(block.id) ?? (BLOCK_HEIGHT_ESTIMATES[block.type] ?? 40)
63
+ },
64
+ overscan: OVERSCAN,
65
+ gap: BLOCK_GAP,
66
+ getItemKey: (index: number) => {
67
+ const block = blocks.value[index]
68
+ return block ? block.id : index
69
+ },
70
+ })),
71
+ )
72
+
73
+ const virtualItems = computed(() => virtualizer.value.getVirtualItems())
74
+ const totalSize = computed(() => virtualizer.value.getTotalSize())
75
+
76
+ function measureElement(el: Element | null) {
77
+ if (!el) return
78
+ const index = Number((el as HTMLElement).dataset['index'])
79
+ const block = blocks.value[index]
80
+ virtualizer.value.measureElement(el as HTMLElement)
81
+ const nextMeasurement = virtualizer.value.measurementsCache[index]
82
+ if (block && nextMeasurement) heightCache.set(block.id, nextMeasurement.size)
83
+ }
84
+
85
+ const shouldAutoScroll = ref(true)
86
+ let isProgrammaticScroll = false
87
+ let pendingIntent: ScrollIntent = null
88
+ let touchStartY = 0
89
+ let furthestDistanceSinceDisengage = 0
90
+
91
+ function scrollToBottom() {
92
+ isProgrammaticScroll = true
93
+ virtualizer.value.scrollToIndex(blocks.value.length - 1, { align: 'end' })
94
+ isProgrammaticScroll = false
95
+ furthestDistanceSinceDisengage = 0
96
+ }
97
+
98
+ function scrollToBottomExplicit() {
99
+ shouldAutoScroll.value = true
100
+ scrollToBottom()
101
+ }
102
+
103
+ function updateAutoScrollState(dist: number, threshold: number) {
104
+ if (pendingIntent === 'up' || !shouldAutoScroll.value) {
105
+ furthestDistanceSinceDisengage = Math.max(furthestDistanceSinceDisengage, dist)
106
+ }
107
+ if (isProgrammaticScroll) return
108
+ if (pendingIntent === 'up') {
109
+ shouldAutoScroll.value = false
110
+ } else if (pendingIntent === 'down' && dist <= threshold && furthestDistanceSinceDisengage > threshold) {
111
+ shouldAutoScroll.value = true
112
+ furthestDistanceSinceDisengage = 0
113
+ } else if (dist <= AUTO_SCROLL_REENGAGE_THRESHOLD) {
114
+ shouldAutoScroll.value = true
115
+ furthestDistanceSinceDisengage = 0
116
+ } else if (dist > threshold) {
117
+ shouldAutoScroll.value = false
118
+ }
119
+ }
120
+
121
+ watch(
122
+ scrollContainer,
123
+ (el, _, onCleanup) => {
124
+ if (!el) return
125
+ const onWheel = (e: WheelEvent) => {
126
+ if (e.deltaY < 0) pendingIntent = 'up'
127
+ if (e.deltaY > 0) pendingIntent = 'down'
128
+ }
129
+ const onTouchStart = (e: TouchEvent) => {
130
+ touchStartY = e.touches[0]?.clientY ?? 0
131
+ }
132
+ const onTouchMove = (e: TouchEvent) => {
133
+ const touchY = e.touches[0]?.clientY ?? touchStartY
134
+ if (touchY > touchStartY) pendingIntent = 'up'
135
+ if (touchY < touchStartY) pendingIntent = 'down'
136
+ touchStartY = touchY
137
+ }
138
+ el.addEventListener('wheel', onWheel, { passive: true })
139
+ el.addEventListener('touchstart', onTouchStart, { passive: true })
140
+ el.addEventListener('touchmove', onTouchMove, { passive: true })
141
+ onCleanup(() => {
142
+ el.removeEventListener('wheel', onWheel)
143
+ el.removeEventListener('touchstart', onTouchStart)
144
+ el.removeEventListener('touchmove', onTouchMove)
145
+ })
146
+ },
147
+ { immediate: true },
148
+ )
149
+
150
+ watch(
151
+ () => virtualizer.value.getTotalSize(),
152
+ () => {
153
+ if (!isRestored.value || !shouldAutoScroll.value) return
154
+ scrollToBottom()
155
+ },
156
+ { flush: 'post' },
157
+ )
158
+
159
+ watch(
160
+ virtualizer,
161
+ (instance) => {
162
+ instance.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, currentInstance) => {
163
+ const internalInstance = currentInstance as unknown as { getScrollOffset(): number; scrollAdjustments: number }
164
+ const offset = internalInstance.getScrollOffset()
165
+ const isStreamingTail = item.index === blocks.value.length - 1
166
+ const allowCorrection = shouldAutoScroll.value || !isStreamingTail
167
+ return allowCorrection && item.start < offset + internalInstance.scrollAdjustments
168
+ }
169
+ },
170
+ { immediate: true },
171
+ )
172
+
173
+ let lastAnchor: ScrollAnchor | null = null
174
+
175
+ function updateAnchor() {
176
+ const el = scrollContainer.value
177
+ if (!el || blocks.value.length === 0) return
178
+ const st = el.scrollTop
179
+ const containerTop = el.getBoundingClientRect().top
180
+ for (const item of virtualizer.value.getVirtualItems()) {
181
+ if (item.start + item.size > st) {
182
+ const anchorEl = el.querySelector(`[data-index="${item.index}"]`) as HTMLElement | null
183
+ if (!anchorEl) return
184
+ lastAnchor = {
185
+ blockId: blocks.value[item.index]!.id,
186
+ anchorIndex: item.index,
187
+ offsetPx: anchorEl.getBoundingClientRect().top - containerTop,
188
+ scrollTop: st,
189
+ }
190
+ return
191
+ }
192
+ }
193
+ }
194
+
195
+ function onScroll() {
196
+ const el = scrollContainer.value
197
+ if (el) {
198
+ scrollOffset.value = el.scrollTop
199
+ const dist = el.scrollHeight - el.scrollTop - el.clientHeight
200
+ isAtBottom.value = dist <= BOTTOM_THRESHOLD
201
+ updateAutoScrollState(dist, BOTTOM_THRESHOLD)
202
+ pendingIntent = null
203
+ }
204
+ updateAnchor()
205
+ }
206
+
207
+ function restoreScroll() {
208
+ const el = scrollContainer.value
209
+ if (!el || blocks.value.length === 0 || isRestored.value) return
210
+ isRestored.value = true
211
+
212
+ if (!persistedState) {
213
+ scrollToBottom()
214
+ nextTick(() => updateAnchor())
215
+ return
216
+ }
217
+
218
+ const anchorIndex = blocks.value.findIndex((b) => b.id === persistedState.anchor.blockId)
219
+ if (anchorIndex < 0) {
220
+ scrollToBottom()
221
+ nextTick(() => updateAnchor())
222
+ return
223
+ }
224
+
225
+ el.scrollTop = persistedState.anchor.scrollTop
226
+ correctUntilConverged(el, anchorIndex, persistedState.anchor.offsetPx, 3)
227
+ }
228
+
229
+ function correctUntilConverged(el: HTMLElement, anchorIndex: number, targetOffset: number, maxPasses: number) {
230
+ waitForScrollStable(el, () => {
231
+ const anchorEl = el.querySelector(`[data-index="${anchorIndex}"]`) as HTMLElement | null
232
+ if (!anchorEl) {
233
+ updateAnchor()
234
+ return
235
+ }
236
+ const correction = anchorEl.getBoundingClientRect().top - el.getBoundingClientRect().top - targetOffset
237
+ if (Math.abs(correction) > 1 && maxPasses > 0) {
238
+ el.scrollTop += correction
239
+ correctUntilConverged(el, anchorIndex, targetOffset, maxPasses - 1)
240
+ } else {
241
+ updateAnchor()
242
+ }
243
+ })
244
+ }
245
+
246
+ watch(
247
+ () => [blocks.value.length, scrollContainer.value] as const,
248
+ ([len, el]) => {
249
+ if (!isRestored.value && len > 0 && el) {
250
+ nextTick(() => restoreScroll())
251
+ }
252
+ },
253
+ { immediate: true, flush: 'post' },
254
+ )
255
+
256
+ function getPersistedState(): PersistedScrollState | undefined {
257
+ if (!lastAnchor) return undefined
258
+ return {
259
+ anchor: { ...lastAnchor, scrollTop: scrollContainer.value?.scrollTop ?? lastAnchor.scrollTop },
260
+ heightCache: new Map(heightCache),
261
+ }
262
+ }
263
+
264
+ return {
265
+ virtualizer,
266
+ virtualItems,
267
+ totalSize,
268
+ measureElement,
269
+ scrollOffset,
270
+ isAtBottom,
271
+ scrollToBottom: scrollToBottomExplicit,
272
+ onScroll,
273
+ getPersistedState,
274
+ }
275
+ }
276
+
277
+ function waitForScrollStable(el: HTMLElement, callback: () => void) {
278
+ let prev = el.scrollTop
279
+ let stable = 0
280
+ function check() {
281
+ if (el.scrollTop === prev) {
282
+ if (++stable >= 2) return callback()
283
+ } else {
284
+ stable = 0
285
+ prev = el.scrollTop
286
+ }
287
+ requestAnimationFrame(check)
288
+ }
289
+ requestAnimationFrame(check)
290
+ }
@@ -0,0 +1,44 @@
1
+ import { computed, onScopeDispose, ref } from 'vue'
2
+
3
+ const activeId = ref<symbol | null>(null)
4
+ const anchorX = ref(0)
5
+ const anchorY = ref(0)
6
+ const menuKey = ref(0)
7
+
8
+ const closeCallbacks = new Map<symbol, () => void>()
9
+
10
+ function closeActive() {
11
+ if (activeId.value === null) return
12
+ const cb = closeCallbacks.get(activeId.value)
13
+ activeId.value = null
14
+ cb?.()
15
+ }
16
+
17
+ export function useContextMenuOwner(onClose?: () => void) {
18
+ const id = Symbol()
19
+ if (onClose) closeCallbacks.set(id, onClose)
20
+
21
+ const isOpen = computed(() => activeId.value === id)
22
+
23
+ function open(event: MouseEvent) {
24
+ closeActive()
25
+ event.preventDefault()
26
+ anchorX.value = event.clientX
27
+ anchorY.value = event.clientY
28
+ menuKey.value++
29
+ activeId.value = id
30
+ }
31
+
32
+ function close() {
33
+ if (activeId.value !== id) return
34
+ activeId.value = null
35
+ onClose?.()
36
+ }
37
+
38
+ onScopeDispose(() => {
39
+ closeCallbacks.delete(id)
40
+ if (activeId.value === id) activeId.value = null
41
+ })
42
+
43
+ return { isOpen, anchorX, anchorY, menuKey, open, close }
44
+ }
@@ -0,0 +1,18 @@
1
+ import { onBeforeUnmount, watch, type WatchSource } from 'vue'
2
+ import type { OverlayStore } from '../overlay/overlayStore'
3
+
4
+ export function useOverlay(store: OverlayStore, isOpen: WatchSource<boolean>, close: () => void): void {
5
+ const id = crypto.randomUUID()
6
+ let remove = () => {}
7
+
8
+ watch(isOpen, (open) => {
9
+ remove()
10
+ if (!open) return
11
+ remove = store.push(id, close)
12
+ }, { immediate: true })
13
+
14
+ onBeforeUnmount(() => {
15
+ remove()
16
+ store.remove(id)
17
+ })
18
+ }