@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,422 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, ref, watch } from 'vue'
3
+ import { AddLine } from '@mingcute/vue/add'
4
+ import { reportError } from '@demicodes/web-ui/infra/errors'
5
+ import { useContextMenuOwner } from '@demicodes/web-ui/composables/useContextMenuOwner'
6
+ import { t } from '@demicodes/web-ui/infra/i18n'
7
+ import { appOverlayStore } from '@demicodes/web-ui/overlay/appOverlay'
8
+ import Popover from '@demicodes/web-ui/ui/Popover.vue'
9
+ import Menu from '@demicodes/web-ui/ui/Menu.vue'
10
+ import MenuItem from '@demicodes/web-ui/ui/MenuItem.vue'
11
+ import MenuDivider from '@demicodes/web-ui/ui/MenuDivider.vue'
12
+ import Tooltip from '@demicodes/web-ui/ui/Tooltip.vue'
13
+ import { useAgentWorkspace } from './workspace'
14
+ import { conversationStatus } from './conversation-status'
15
+ import type { ConversationState } from './types'
16
+ import AgentTabItem from './AgentTabItem.vue'
17
+ import ConversationListDropdown from './ConversationListDropdown.vue'
18
+
19
+ const DRAG_THRESHOLD = 3
20
+
21
+ const props = defineProps<{
22
+ tabs: ConversationState[]
23
+ allConversations: ConversationState[]
24
+ activeTabId: string | null
25
+ }>()
26
+
27
+ const workspace = useAgentWorkspace()
28
+
29
+ const localTabs = ref<ConversationState[]>([...props.tabs])
30
+ const isDragging = ref(false)
31
+ const containerRef = ref<HTMLElement>()
32
+ const dragIdx = ref(-1)
33
+ const dragDeltaX = ref(0)
34
+ const tabShifts = ref<number[]>([])
35
+ const closingTabIds = ref(new Set<string>())
36
+ const enteringTabIds = ref(new Set<string>())
37
+ const isSettling = ref(false)
38
+
39
+ const focusedTabId = ref<string | null>(null)
40
+ const pendingRenameTabId = ref<string | null>(null)
41
+ const renameValue = ref('')
42
+
43
+ let startX = 0
44
+ let tabRects: DOMRect[] = []
45
+ let hasDragged = false
46
+ let currentNewIdx = -1
47
+ let closeTimer: ReturnType<typeof setTimeout> | null = null
48
+ let settleTimer: ReturnType<typeof setTimeout> | null = null
49
+
50
+ const {
51
+ isOpen: isContextMenuOpen,
52
+ anchorX: contextMenuX,
53
+ anchorY: contextMenuY,
54
+ open: openContextMenu,
55
+ close: closeContextMenu,
56
+ } = useContextMenuOwner(() => {
57
+ focusedTabId.value = null
58
+ })
59
+
60
+ const focusedTabIndex = computed(() =>
61
+ localTabs.value.findIndex((tab) => tab.id === (focusedTabId.value ?? props.activeTabId)),
62
+ )
63
+ const hasOtherTabs = computed(() => localTabs.value.length > 1)
64
+ const hasTabsToLeft = computed(() => focusedTabIndex.value > 0)
65
+ const hasTabsToRight = computed(() => focusedTabIndex.value < localTabs.value.length - 1)
66
+
67
+ watch(() => props.tabs, (newTabs, oldTabs) => {
68
+ if (isDragging.value) return
69
+
70
+ if (oldTabs) {
71
+ const oldIds = new Set(oldTabs.map((tab) => tab.id))
72
+ for (const tab of newTabs) {
73
+ if (!oldIds.has(tab.id)) enteringTabIds.value.add(tab.id)
74
+ }
75
+ }
76
+
77
+ if (closingTabIds.value.size > 0) {
78
+ const newTabIds = new Set(newTabs.map((tab) => tab.id))
79
+ closingTabIds.value = new Set([...closingTabIds.value].filter((id) => newTabIds.has(id)))
80
+ }
81
+
82
+ localTabs.value = [...newTabs]
83
+
84
+ if (enteringTabIds.value.size > 0) {
85
+ nextTick(() => {
86
+ requestAnimationFrame(() => {
87
+ requestAnimationFrame(() => {
88
+ enteringTabIds.value = new Set()
89
+ })
90
+ })
91
+ })
92
+ }
93
+ }, { deep: true, immediate: true })
94
+
95
+ watch(pendingRenameTabId, (id) => {
96
+ if (!id) return
97
+ const tab = localTabs.value.find((entry) => entry.id === id)
98
+ if (!tab) return
99
+ renameValue.value = tab.title
100
+ })
101
+
102
+ // ── Navigation ──
103
+
104
+ function navigateToConversation(conversationId: string) {
105
+ workspace.setActive(conversationId)
106
+ }
107
+
108
+ // ── Tab operations ──
109
+
110
+ function handleCreateTab(afterConversationId?: string) {
111
+ workspace.createConversation(afterConversationId ? { afterId: afterConversationId } : {})
112
+ }
113
+
114
+ function handleCloseTabs(conversationIds: string[]) {
115
+ if (conversationIds.length === 0) return
116
+ if (closingTabIds.value.size > 0) finishClose()
117
+
118
+ closingTabIds.value = new Set(conversationIds)
119
+ closeTimer = setTimeout(finishClose, 300)
120
+ }
121
+
122
+ function handleRenameTab(conversationId: string, newTitle: string) {
123
+ workspace.renameConversation(conversationId, newTitle)
124
+ }
125
+
126
+ // The history menu shows every persisted conversation (from the server) plus
127
+ // any open tab not yet persisted; selecting one opens it (resuming if needed).
128
+ const historyConversations = computed(() => {
129
+ const byId = new Map<string, { id: string; title: string; createdAt: string }>()
130
+ for (const summary of workspace.serverConversations.value) byId.set(summary.id, summary)
131
+ for (const tab of props.allConversations) if (!byId.has(tab.id)) byId.set(tab.id, tab)
132
+ return [...byId.values()]
133
+ })
134
+
135
+ function handleOpenConversation(conversationId: string) {
136
+ workspace.openConversation({ id: conversationId })
137
+ }
138
+
139
+ // ── Context menu ──
140
+
141
+ function handleTabContextMenu(event: MouseEvent, tabId: string) {
142
+ if (isDragging.value || isSettling.value) return
143
+ focusedTabId.value = tabId
144
+ openContextMenu(event)
145
+ }
146
+
147
+ function handleCopyConversationId() {
148
+ const id = focusedTabId.value ?? props.activeTabId
149
+ if (id) void navigator.clipboard.writeText(id)
150
+ }
151
+
152
+ function handleNewTabToRight() {
153
+ const tabId = focusedTabId.value ?? props.activeTabId
154
+ if (tabId) handleCreateTab(tabId)
155
+ }
156
+
157
+ function handleRenameFocused() {
158
+ pendingRenameTabId.value = focusedTabId.value ?? props.activeTabId
159
+ }
160
+
161
+ function handleCloseFocused() {
162
+ const tabId = focusedTabId.value ?? props.activeTabId
163
+ if (tabId) handleCloseTab(tabId)
164
+ }
165
+
166
+ function handleCloseOthers() {
167
+ const tabId = focusedTabId.value ?? props.activeTabId
168
+ handleCloseTabs(localTabs.value.filter((tab) => tab.id !== tabId).map((tab) => tab.id))
169
+ }
170
+
171
+ function handleCloseToLeft() {
172
+ handleCloseTabs(localTabs.value.slice(0, focusedTabIndex.value).map((tab) => tab.id))
173
+ }
174
+
175
+ function handleCloseToRight() {
176
+ handleCloseTabs(localTabs.value.slice(focusedTabIndex.value + 1).map((tab) => tab.id))
177
+ }
178
+
179
+ function handleCloseAll() {
180
+ handleCloseTabs(localTabs.value.map((tab) => tab.id))
181
+ }
182
+
183
+ // ── Rename ──
184
+
185
+ function submitRename() {
186
+ const id = pendingRenameTabId.value
187
+ if (!id) return
188
+ const tab = localTabs.value.find((entry) => entry.id === id)
189
+ const trimmed = renameValue.value.trim()
190
+ pendingRenameTabId.value = null
191
+ if (trimmed && trimmed !== tab?.title) {
192
+ handleRenameTab(id, trimmed)
193
+ }
194
+ }
195
+
196
+ function cancelRename() {
197
+ pendingRenameTabId.value = null
198
+ }
199
+
200
+ // ── Tab close animation ──
201
+
202
+ function handleCloseTab(tabId: string) {
203
+ if (closingTabIds.value.size > 0) finishClose()
204
+
205
+ closingTabIds.value = new Set([tabId])
206
+ closeTimer = setTimeout(finishClose, 300)
207
+ }
208
+
209
+ function finishClose() {
210
+ if (closeTimer) {
211
+ clearTimeout(closeTimer)
212
+ closeTimer = null
213
+ }
214
+ if (closingTabIds.value.size === 0) return
215
+
216
+ const ids = [...closingTabIds.value]
217
+ closingTabIds.value = new Set()
218
+
219
+ const closedSet = new Set(ids)
220
+ localTabs.value = localTabs.value.filter((tab) => !closedSet.has(tab.id))
221
+
222
+ void closeTabs(ids)
223
+ }
224
+
225
+ async function closeTabs(conversationIds: readonly string[]) {
226
+ try {
227
+ for (const conversationId of conversationIds) {
228
+ await workspace.closeConversation(conversationId)
229
+ }
230
+ } catch (error) {
231
+ localTabs.value = [...props.tabs]
232
+ reportError('Failed to close conversation tabs', error, { userVisible: true })
233
+ }
234
+ }
235
+
236
+ function handleTransitionEnd(event: TransitionEvent, tabId: string) {
237
+ if (!closingTabIds.value.has(tabId)) return
238
+ if (event.propertyName !== 'max-width') return
239
+ finishClose()
240
+ }
241
+
242
+ // ── Drag ──
243
+
244
+ function getTabShift(index: number): number {
245
+ if (!isDragging.value && !isSettling.value) return 0
246
+ return index === dragIdx.value ? dragDeltaX.value : (tabShifts.value[index] ?? 0)
247
+ }
248
+
249
+ function handlePointerDown(event: PointerEvent, index: number) {
250
+ if (event.button !== 0) return
251
+ if (closingTabIds.value.size > 0) return
252
+ if (isSettling.value) return
253
+ const target = event.currentTarget as HTMLElement
254
+ target.setPointerCapture(event.pointerId)
255
+ startX = event.clientX
256
+ dragIdx.value = index
257
+ hasDragged = false
258
+ currentNewIdx = index
259
+ const children = containerRef.value!.children
260
+ tabRects = Array.from(children).map((el) => (el as HTMLElement).getBoundingClientRect())
261
+ }
262
+
263
+ function handlePointerMove(event: PointerEvent) {
264
+ if (dragIdx.value < 0 || isSettling.value) return
265
+
266
+ const deltaX = event.clientX - startX
267
+ if (!hasDragged && Math.abs(deltaX) < DRAG_THRESHOLD) return
268
+
269
+ if (!hasDragged) {
270
+ hasDragged = true
271
+ isDragging.value = true
272
+ tabShifts.value = new Array(localTabs.value.length).fill(0)
273
+ }
274
+
275
+ dragDeltaX.value = deltaX
276
+
277
+ const draggedRect = tabRects[dragIdx.value]!
278
+ const draggedLeft = draggedRect.left + deltaX
279
+ const draggedRight = draggedRect.right + deltaX
280
+ const draggedWidth = draggedRect.width
281
+ const shifts = new Array(localTabs.value.length).fill(0)
282
+ let newIdx = dragIdx.value
283
+
284
+ for (let i = 0; i < tabRects.length; i++) {
285
+ if (i === dragIdx.value) continue
286
+ const otherCenter = tabRects[i]!.left + tabRects[i]!.width / 2
287
+
288
+ if (i > dragIdx.value && draggedRight > otherCenter) {
289
+ shifts[i] = -draggedWidth
290
+ newIdx = Math.max(newIdx, i)
291
+ } else if (i < dragIdx.value && draggedLeft < otherCenter) {
292
+ shifts[i] = draggedWidth
293
+ newIdx = Math.min(newIdx, i)
294
+ }
295
+ }
296
+
297
+ tabShifts.value = shifts
298
+ currentNewIdx = newIdx
299
+ }
300
+
301
+ function handlePointerUp() {
302
+ if (dragIdx.value < 0 || isSettling.value) return
303
+
304
+ const idx = dragIdx.value
305
+
306
+ if (!hasDragged) {
307
+ dragIdx.value = -1
308
+ navigateToConversation(localTabs.value[idx]!.id)
309
+ return
310
+ }
311
+
312
+ isSettling.value = true
313
+
314
+ let targetDeltaX = 0
315
+ if (currentNewIdx !== idx) {
316
+ if (currentNewIdx > idx) {
317
+ targetDeltaX = tabRects[currentNewIdx]!.right - tabRects[idx]!.right
318
+ } else {
319
+ targetDeltaX = tabRects[currentNewIdx]!.left - tabRects[idx]!.left
320
+ }
321
+ }
322
+
323
+ requestAnimationFrame(() => {
324
+ requestAnimationFrame(() => {
325
+ dragDeltaX.value = targetDeltaX
326
+ })
327
+ })
328
+
329
+ settleTimer = setTimeout(finishSettle, 140)
330
+ }
331
+
332
+ function finishSettle() {
333
+ if (settleTimer) {
334
+ clearTimeout(settleTimer)
335
+ settleTimer = null
336
+ }
337
+
338
+ const idx = dragIdx.value
339
+ const newIdx = currentNewIdx
340
+
341
+ isSettling.value = false
342
+ isDragging.value = false
343
+ dragIdx.value = -1
344
+ dragDeltaX.value = 0
345
+ tabShifts.value = []
346
+
347
+ if (newIdx !== idx && idx >= 0) {
348
+ const [tab] = localTabs.value.splice(idx, 1)
349
+ localTabs.value.splice(newIdx, 0, tab!)
350
+ workspace.reorderTabs(localTabs.value.map((entry) => entry.id))
351
+ }
352
+ }
353
+ </script>
354
+
355
+ <template>
356
+ <div class="flex h-11 shrink-0 items-center bg-surface-base px-2">
357
+ <div ref="containerRef" class="titlebar-no-drag flex min-w-0 items-center gap-0.5 overflow-x-auto scrollbar-hidden">
358
+ <AgentTabItem
359
+ v-for="(tab, index) in localTabs"
360
+ :key="tab.id"
361
+ :tab="tab"
362
+ :is-active="tab.id === activeTabId"
363
+ :status="conversationStatus(tab)"
364
+ :provider-icon-id="tab.model.providerId"
365
+ :is-closing="closingTabIds.has(tab.id)"
366
+ :is-entering="enteringTabIds.has(tab.id)"
367
+ :is-dragging="isDragging"
368
+ :is-drag-target="isDragging && index === dragIdx"
369
+ :is-settling="isSettling"
370
+ :shift="getTabShift(index)"
371
+ :is-renaming="pendingRenameTabId === tab.id"
372
+ :rename-value="renameValue"
373
+ @pointerdown="handlePointerDown($event, index)"
374
+ @pointermove="handlePointerMove"
375
+ @pointerup="handlePointerUp"
376
+ @lostpointercapture="handlePointerUp"
377
+ @transitionend="handleTransitionEnd($event, tab.id)"
378
+ @contextmenu="handleTabContextMenu($event, tab.id)"
379
+ @close="handleCloseTab(tab.id)"
380
+ @rename-submit="submitRename"
381
+ @rename-cancel="cancelRename"
382
+ @update:rename-value="renameValue = $event"
383
+ />
384
+ </div>
385
+ <Tooltip
386
+ content=""
387
+ class="titlebar-no-drag ml-1 flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-fg-faint transition-colors hover:bg-hover hover:text-fg-muted"
388
+ @click="handleCreateTab()"
389
+ >
390
+ <AddLine :size="14" />
391
+ </Tooltip>
392
+ <span class="titlebar-no-drag ml-auto">
393
+ <ConversationListDropdown
394
+ :conversations="historyConversations"
395
+ :active-tab-id="activeTabId"
396
+ @select="handleOpenConversation"
397
+ />
398
+ </span>
399
+ </div>
400
+ <Popover
401
+ :overlay-store="appOverlayStore"
402
+ :is-open="isContextMenuOpen"
403
+ :anchor-x="contextMenuX"
404
+ :anchor-y="contextMenuY"
405
+ :offset="0"
406
+ @close="closeContextMenu"
407
+ >
408
+ <Menu @click="closeContextMenu">
409
+ <MenuItem :label="t('agent.tab.newTabRight')" @select="handleNewTabToRight" />
410
+ <MenuDivider />
411
+ <MenuItem :label="t('common.rename')" @select="handleRenameFocused" />
412
+ <MenuDivider />
413
+ <MenuItem :label="t('common.close')" @select="handleCloseFocused" />
414
+ <MenuItem :label="t('agent.tab.closeOthers')" :disabled="!hasOtherTabs" @select="handleCloseOthers" />
415
+ <MenuItem :label="t('agent.tab.closeToLeft')" :disabled="!hasTabsToLeft" @select="handleCloseToLeft" />
416
+ <MenuItem :label="t('agent.tab.closeToRight')" :disabled="!hasTabsToRight" @select="handleCloseToRight" />
417
+ <MenuItem :label="t('agent.tab.closeAll')" @select="handleCloseAll" />
418
+ <MenuDivider />
419
+ <MenuItem :label="t('agent.tab.copyConversationId')" @select="handleCopyConversationId" />
420
+ </Menu>
421
+ </Popover>
422
+ </template>
@@ -0,0 +1,150 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, ref, watch } from 'vue'
3
+ import { CloseLine } from '@mingcute/vue/close'
4
+ import type { ConversationState } from './types'
5
+ import type { ConversationStatus } from './conversation-status'
6
+ import ProviderIcon from './providers/ProviderIcon.vue'
7
+ import Tooltip from '@demicodes/web-ui/ui/Tooltip.vue'
8
+ import ConversationStatusDot from './ConversationStatusDot.vue'
9
+ import { useAgentUiOptions } from './ui-options'
10
+
11
+ const uiOptions = useAgentUiOptions()
12
+
13
+ const TAB_TRANSITION = 'max-width 200ms ease-out, min-width 200ms ease-out, padding 200ms ease-out, opacity 150ms ease, background-color 150ms ease, color 150ms ease'
14
+ const DRAG_TRANSITION = 'transform 120ms ease'
15
+
16
+ const props = defineProps<{
17
+ tab: ConversationState
18
+ isActive: boolean
19
+ status: ConversationStatus
20
+ isClosing: boolean
21
+ isEntering: boolean
22
+ isDragging: boolean
23
+ isDragTarget: boolean
24
+ isSettling: boolean
25
+ shift: number
26
+ isRenaming: boolean
27
+ renameValue: string
28
+ providerIconId: string | null
29
+ }>()
30
+
31
+ const emit = defineEmits<{
32
+ pointerdown: [event: PointerEvent]
33
+ pointermove: [event: PointerEvent]
34
+ pointerup: []
35
+ lostpointercapture: []
36
+ transitionend: [event: TransitionEvent]
37
+ contextmenu: [event: MouseEvent]
38
+ close: []
39
+ renameSubmit: []
40
+ renameCancel: []
41
+ 'update:renameValue': [value: string]
42
+ }>()
43
+
44
+ const renameInputRef = ref<HTMLInputElement | null>(null)
45
+
46
+ watch(() => props.isRenaming, (val) => {
47
+ if (!val) return
48
+ nextTick(() => {
49
+ renameInputRef.value?.focus()
50
+ renameInputRef.value?.select()
51
+ })
52
+ })
53
+
54
+ const tabStyle = computed(() => {
55
+ const isCollapsed = props.isClosing || props.isEntering
56
+
57
+ if (props.isDragging || props.isSettling) {
58
+ return {
59
+ maxWidth: isCollapsed ? '0px' : '220px',
60
+ minWidth: isCollapsed ? '0px' : undefined,
61
+ paddingLeft: isCollapsed ? '0px' : undefined,
62
+ paddingRight: isCollapsed ? '0px' : undefined,
63
+ opacity: isCollapsed ? '0' : undefined,
64
+ transform: `translateX(${props.shift}px)`,
65
+ transition: (props.isSettling || !props.isDragTarget) ? DRAG_TRANSITION : undefined,
66
+ }
67
+ }
68
+
69
+ return isCollapsed
70
+ ? { maxWidth: '0px', minWidth: '0px', paddingLeft: '0px', paddingRight: '0px', opacity: '0', transition: TAB_TRANSITION }
71
+ : { maxWidth: '220px', transition: TAB_TRANSITION }
72
+ })
73
+ </script>
74
+
75
+ <template>
76
+ <span
77
+ class="relative flex shrink cursor-pointer items-center overflow-hidden rounded-lg text-[13px] select-none touch-none"
78
+ :class="[
79
+ isActive
80
+ ? 'bg-surface text-fg-emphasis'
81
+ : isDragging && isDragTarget
82
+ ? 'bg-surface text-fg-body'
83
+ : 'text-fg-subtle',
84
+ !isDragging && 'group',
85
+ !isDragging && !isActive && 'hover:bg-surface hover:text-fg-body',
86
+ isDragging && isDragTarget && 'z-50',
87
+ isClosing && 'pointer-events-none',
88
+ ]"
89
+ :style="tabStyle"
90
+ @pointerdown="emit('pointerdown', $event)"
91
+ @pointermove="emit('pointermove', $event)"
92
+ @pointerup="emit('pointerup')"
93
+ @lostpointercapture="emit('lostpointercapture')"
94
+ @transitionend="emit('transitionend', $event)"
95
+ @contextmenu.prevent="emit('contextmenu', $event)"
96
+ >
97
+ <span
98
+ v-if="uiOptions.showTabIcon"
99
+ class="relative ml-1.5 flex shrink-0 items-center justify-center"
100
+ >
101
+ <ProviderIcon
102
+ v-if="providerIconId"
103
+ :provider-id="providerIconId"
104
+ :size="18"
105
+ class="text-fg-subtle"
106
+ />
107
+ <span
108
+ v-else
109
+ class="inline-block size-4 rounded-full bg-surface-raised"
110
+ />
111
+ <ConversationStatusDot
112
+ :status="status"
113
+ />
114
+ </span>
115
+ <input
116
+ v-if="isRenaming"
117
+ ref="renameInputRef"
118
+ :value="renameValue"
119
+ class="w-32 truncate bg-transparent pl-1.5 pr-1.5 py-1.5 outline-none"
120
+ @input="emit('update:renameValue', ($event.target as HTMLInputElement).value)"
121
+ @keydown.enter="emit('renameSubmit')"
122
+ @keydown.escape="emit('renameCancel')"
123
+ @blur="emit('renameSubmit')"
124
+ @pointerdown.stop
125
+ @click.stop
126
+ />
127
+ <Tooltip v-else :content="tab.title" placement="bottom" class="w-32 truncate whitespace-nowrap pl-1.5 pr-1.5 py-1.5">{{ tab.title }}</Tooltip>
128
+ <span
129
+ class="pointer-events-none absolute inset-y-0 right-0 z-10 flex w-9 items-center justify-end pr-1.5 opacity-0 transition-opacity group-hover:opacity-100"
130
+ >
131
+ <span
132
+ class="absolute inset-y-0 left-0 w-4"
133
+ :class="isActive
134
+ ? 'bg-linear-to-r from-surface/0 to-surface'
135
+ : 'bg-linear-to-r from-surface-base/0 to-surface-base group-hover:from-surface/0 group-hover:to-surface'"
136
+ />
137
+ <span
138
+ class="absolute inset-y-0 right-0 w-6"
139
+ :class="isActive ? 'bg-surface' : 'bg-surface-base group-hover:bg-surface'"
140
+ />
141
+ <span
142
+ class="pointer-events-auto relative z-10 flex size-5 shrink-0 items-center justify-center rounded text-fg-faint transition-colors hover:bg-hover hover:text-fg-body"
143
+ @pointerdown.stop
144
+ @click.stop="emit('close')"
145
+ >
146
+ <CloseLine :size="12" />
147
+ </span>
148
+ </span>
149
+ </span>
150
+ </template>
@@ -0,0 +1,136 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import type { TokenUsage } from '@demicodes/core'
4
+ import IndeterminateSpinner from '@demicodes/web-ui/ui/IndeterminateSpinner.vue'
5
+ import { t } from '@demicodes/web-ui/infra/i18n'
6
+
7
+ const props = defineProps<{
8
+ conversationId?: string
9
+ usage?: TokenUsage | null
10
+ contextWindow?: number | null
11
+ inputLimit?: number | null
12
+ isCompacting?: boolean
13
+ isClickable?: boolean
14
+ instructionFiles?: string[]
15
+ }>()
16
+
17
+ const emit = defineEmits<{
18
+ compact: []
19
+ }>()
20
+
21
+ const isHovered = ref(false)
22
+
23
+ const EMPTY_USAGE: TokenUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
24
+
25
+ const displayUsage = computed(() => props.usage ?? EMPTY_USAGE)
26
+ const isUsageAvailable = computed(() => props.usage != null)
27
+ const isTokenLimitAvailable = computed(() =>
28
+ (props.inputLimit != null && props.inputLimit > 0)
29
+ || (props.contextWindow != null && props.contextWindow > 0),
30
+ )
31
+ const usedTokens = computed(() =>
32
+ displayUsage.value.inputTokens + displayUsage.value.outputTokens
33
+ + (displayUsage.value.cacheReadTokens ?? 0) + (displayUsage.value.cacheWriteTokens ?? 0),
34
+ )
35
+ const effectiveLimit = computed(() => {
36
+ if (props.inputLimit != null && props.inputLimit > 0) return props.inputLimit
37
+ if (props.contextWindow != null && props.contextWindow > 0) return props.contextWindow
38
+ return 1
39
+ })
40
+ const ratio = computed(() => {
41
+ if (!isTokenLimitAvailable.value) return 0
42
+ return Math.min(usedTokens.value / effectiveLimit.value, 1)
43
+ })
44
+ const percentage = computed(() => Math.round(ratio.value * 100))
45
+
46
+ const radius = 5.5
47
+ const circumference = 2 * Math.PI * radius
48
+ const strokeDashoffset = computed(() => circumference * (1 - ratio.value))
49
+
50
+ const ringColor = computed(() => {
51
+ if (!isTokenLimitAvailable.value) return 'text-fg-subtle'
52
+ if (ratio.value >= 0.9) return 'text-on-danger'
53
+ if (ratio.value >= 0.7) return 'text-on-warning'
54
+ return 'text-fg-muted'
55
+ })
56
+
57
+ function formatTokens(tokens: number): string {
58
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
59
+ if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`
60
+ return String(tokens)
61
+ }
62
+
63
+ function handleClick() {
64
+ if (!props.isClickable || props.isCompacting) return
65
+ emit('compact')
66
+ }
67
+ </script>
68
+
69
+ <template>
70
+ <span
71
+ class="relative flex size-7 items-center justify-center rounded-lg transition-colors"
72
+ :class="[
73
+ isClickable && !isCompacting
74
+ ? 'cursor-pointer hover:bg-active'
75
+ : 'cursor-default',
76
+ ]"
77
+ @mouseenter="isHovered = true"
78
+ @mouseleave="isHovered = false"
79
+ @click="handleClick"
80
+ >
81
+ <IndeterminateSpinner v-if="isCompacting" />
82
+ <svg v-else width="14" height="14" viewBox="0 0 14 14" class="-rotate-90">
83
+ <circle
84
+ cx="7" cy="7" :r="radius"
85
+ fill="none"
86
+ stroke="currentColor"
87
+ stroke-width="2.5"
88
+ class="text-overlay/8"
89
+ />
90
+ <circle
91
+ cx="7" cy="7" :r="radius"
92
+ fill="none"
93
+ stroke="currentColor"
94
+ stroke-width="2.5"
95
+ stroke-linecap="round"
96
+ :stroke-dasharray="String(circumference)"
97
+ :stroke-dashoffset="strokeDashoffset"
98
+ :class="ringColor"
99
+ />
100
+ </svg>
101
+
102
+ <Transition
103
+ enter-active-class="transition duration-150 ease-out"
104
+ enter-from-class="opacity-0 translate-y-1"
105
+ enter-to-class="opacity-100 translate-y-0"
106
+ leave-active-class="transition duration-100 ease-in"
107
+ leave-from-class="opacity-100 translate-y-0"
108
+ leave-to-class="opacity-0 translate-y-1"
109
+ >
110
+ <div
111
+ v-if="isHovered"
112
+ class="absolute bottom-full left-1/2 z-50 -translate-x-1/2 cursor-default pb-2"
113
+ @click.stop
114
+ >
115
+ <div class="rounded-lg bg-surface px-3 py-2 text-xs leading-relaxed whitespace-nowrap ring-1 ring-line-subtle shadow-lg">
116
+ <template v-if="isCompacting">
117
+ <div class="text-fg-body">{{ t('agent.context.compacting') }}</div>
118
+ </template>
119
+ <template v-else>
120
+ <template v-if="isTokenLimitAvailable">
121
+ <div class="text-fg">{{ percentage }}% used <span class="text-fg-subtle">({{ formatTokens(usedTokens) }} / {{ formatTokens(effectiveLimit) }})</span></div>
122
+ <div v-if="!isUsageAvailable" class="mt-0.5 text-fg-subtle">{{ t('agent.context.noUsage') }}</div>
123
+ </template>
124
+ <div v-else class="text-fg-muted">{{ t('agent.context.unavailable') }}</div>
125
+ <div
126
+ v-for="file in instructionFiles"
127
+ :key="file"
128
+ class="mt-0.5 max-w-48 truncate text-fg-subtle direction-rtl text-left"
129
+ >{{ file }}</div>
130
+ <div v-if="isClickable" class="mt-1 text-fg-subtle">{{ t('agent.context.compactHint') }}</div>
131
+ </template>
132
+ </div>
133
+ </div>
134
+ </Transition>
135
+ </span>
136
+ </template>