@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,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>
|