@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.
- package/package.json +55 -0
- package/src/agent/AgentMessageInput.vue +164 -0
- package/src/agent/AgentMessageList.vue +124 -0
- package/src/agent/AgentPanel.vue +18 -0
- package/src/agent/AgentRoot.vue +17 -0
- package/src/agent/AgentTabBar.vue +422 -0
- package/src/agent/AgentTabItem.vue +150 -0
- package/src/agent/ContextUsageIndicator.vue +136 -0
- package/src/agent/ConversationListDropdown.vue +71 -0
- package/src/agent/ConversationStatusDot.vue +26 -0
- package/src/agent/ConversationView.vue +82 -0
- package/src/agent/MessageQueueBar.vue +67 -0
- package/src/agent/ModelSelector.vue +125 -0
- package/src/agent/ReasoningSelector.vue +106 -0
- package/src/agent/SelectorTrigger.vue +17 -0
- package/src/agent/__tests__/block-helpers.test.ts +73 -0
- package/src/agent/__tests__/input-actions.test.ts +130 -0
- package/src/agent/__tests__/input-editor.test.ts +15 -0
- package/src/agent/__tests__/pending-steers.test.ts +99 -0
- package/src/agent/__tests__/queue-submit.test.ts +14 -0
- package/src/agent/__tests__/reasoning.test.ts +32 -0
- package/src/agent/__tests__/tail-loading.test.ts +131 -0
- package/src/agent/__tests__/tool-rendering.test.ts +35 -0
- package/src/agent/__tests__/visible-blocks.test.ts +125 -0
- package/src/agent/block-helpers.ts +59 -0
- package/src/agent/block-types.ts +12 -0
- package/src/agent/blocks/AbortedBlock.vue +26 -0
- package/src/agent/blocks/AgentMessageVirtualBlock.vue +88 -0
- package/src/agent/blocks/AnsiText.vue +75 -0
- package/src/agent/blocks/AssistantTextBlock.vue +34 -0
- package/src/agent/blocks/CompactionBlock.vue +36 -0
- package/src/agent/blocks/ErrorBlock.vue +79 -0
- package/src/agent/blocks/FunctionalBlock.vue +118 -0
- package/src/agent/blocks/LoadingBlock.vue +28 -0
- package/src/agent/blocks/ThinkingBlock.vue +86 -0
- package/src/agent/blocks/ToolCallBlock.vue +40 -0
- package/src/agent/blocks/ToolGenericBlock.vue +43 -0
- package/src/agent/blocks/ToolShellAbortBlock.vue +13 -0
- package/src/agent/blocks/ToolShellBlock.vue +47 -0
- package/src/agent/blocks/ToolShellControlBlock.vue +51 -0
- package/src/agent/blocks/ToolShellStatusBlock.vue +13 -0
- package/src/agent/blocks/ToolShellWriteBlock.vue +13 -0
- package/src/agent/blocks/ToolStatusBadge.vue +13 -0
- package/src/agent/blocks/ToolYieldBlock.vue +13 -0
- package/src/agent/blocks/UserBlock.vue +112 -0
- package/src/agent/conversation-runtime.ts +205 -0
- package/src/agent/conversation-status.ts +12 -0
- package/src/agent/message-input/input-model.ts +33 -0
- package/src/agent/message-input/useAgentInputActions.ts +96 -0
- package/src/agent/message-input/useAgentInputEditor.ts +90 -0
- package/src/agent/message-input/useAgentInputSessionState.ts +48 -0
- package/src/agent/pending-steers.ts +134 -0
- package/src/agent/providers/ProviderIcon.vue +32 -0
- package/src/agent/providers/icons/claude.svg +1 -0
- package/src/agent/providers/icons/openai.svg +3 -0
- package/src/agent/queue-submit.ts +4 -0
- package/src/agent/reasoning.ts +45 -0
- package/src/agent/tail-loading.ts +31 -0
- package/src/agent/thinking-streaming.ts +7 -0
- package/src/agent/tool-rendering.ts +59 -0
- package/src/agent/types.ts +39 -0
- package/src/agent/ui-options.ts +21 -0
- package/src/agent/visible-blocks.ts +25 -0
- package/src/agent/workspace.ts +355 -0
- package/src/composables/useBlockVirtualizer.ts +290 -0
- package/src/composables/useContextMenuOwner.ts +44 -0
- package/src/composables/useOverlay.ts +18 -0
- package/src/composables/useTerminalTheme.ts +75 -0
- package/src/env.d.ts +10 -0
- package/src/infra/errors.ts +29 -0
- package/src/infra/i18n.ts +41 -0
- package/src/markdown/filePath.ts +54 -0
- package/src/markdown/highlight.ts +139 -0
- package/src/markdown/md.ts +58 -0
- package/src/markdown/render.ts +117 -0
- package/src/markdown/types.ts +20 -0
- package/src/overlay/appOverlay.ts +3 -0
- package/src/overlay/overlayStore.ts +54 -0
- package/src/store/createStore.ts +33 -0
- package/src/store/useStore.ts +7 -0
- package/src/styles/base.css +342 -0
- package/src/theme/appTheme.ts +56 -0
- package/src/theme/codeThemes.ts +454 -0
- package/src/theme/themeStore.ts +33 -0
- package/src/transport/agent-socket.ts +20 -0
- package/src/transport/control-client.ts +64 -0
- package/src/transport/protocol.ts +57 -0
- package/src/ui/Button.vue +30 -0
- package/src/ui/Checkbox.vue +25 -0
- package/src/ui/ContextMenu.vue +33 -0
- package/src/ui/Dialog.vue +48 -0
- package/src/ui/DropdownMenu.vue +77 -0
- package/src/ui/ErrorBox.vue +43 -0
- package/src/ui/HighlightText.vue +39 -0
- package/src/ui/IconButton.vue +30 -0
- package/src/ui/IndeterminateSpinner.vue +72 -0
- package/src/ui/InlineError.vue +25 -0
- package/src/ui/LoadMore.vue +32 -0
- package/src/ui/MarkdownPreview.vue +31 -0
- package/src/ui/Menu.vue +8 -0
- package/src/ui/MenuDivider.vue +6 -0
- package/src/ui/MenuItem.vue +48 -0
- package/src/ui/OptionMenu.vue +8 -0
- package/src/ui/OptionMenuGroup.vue +16 -0
- package/src/ui/OptionMenuItem.vue +28 -0
- package/src/ui/Popover.vue +90 -0
- package/src/ui/ScrollToBottomButton.vue +32 -0
- package/src/ui/SelectDropdown.vue +231 -0
- package/src/ui/Switch.vue +41 -0
- package/src/ui/TextInput.vue +44 -0
- package/src/ui/ThemeToggle.vue +49 -0
- package/src/ui/ToggleSwitch.vue +34 -0
- package/src/ui/Tooltip.vue +143 -0
- 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
|
+
}
|