@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
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@demicodes/web-ui",
3
+ "version": "0.3.1",
4
+ "type": "module",
5
+ "files": [
6
+ "src"
7
+ ],
8
+ "exports": {
9
+ "./*.vue": "./src/*.vue",
10
+ "./*.css": "./src/*.css",
11
+ "./*": "./src/*.ts"
12
+ },
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/wspl/demi.git",
19
+ "directory": "packages/web-ui"
20
+ },
21
+ "homepage": "https://github.com/wspl/demi/tree/main/packages/web-ui#readme",
22
+ "bugs": "https://github.com/wspl/demi/issues",
23
+ "scripts": {
24
+ "typecheck": "vue-tsc --noEmit -p tsconfig.json"
25
+ },
26
+ "dependencies": {
27
+ "@demicodes/agent": "^0.3.1",
28
+ "@demicodes/core": "^0.3.1",
29
+ "@demicodes/utils": "^0.3.1",
30
+ "@floating-ui/vue": "^2.0.0",
31
+ "@mingcute/vue": "^1.4.1",
32
+ "@tanstack/vue-virtual": "^3.13.29",
33
+ "@tiptap/extension-placeholder": "^3.27.1",
34
+ "@tiptap/pm": "^3.27.1",
35
+ "@tiptap/starter-kit": "^3.27.1",
36
+ "@tiptap/vue-3": "^3.27.1",
37
+ "@vueuse/core": "^14.3.0",
38
+ "ansi-to-html": "^0.7.2",
39
+ "dayjs": "^1.11.21",
40
+ "dompurify": "^3.4.11",
41
+ "katex": "^0.17.0",
42
+ "marked": "^18.0.5",
43
+ "marked-katex-extension": "^5.1.10",
44
+ "partial-json": "^0.1.7",
45
+ "pathe": "^2.0.3",
46
+ "shiki": "^4.2.0",
47
+ "vue": "^3.5.38"
48
+ },
49
+ "devDependencies": {
50
+ "@vue/tsconfig": "^0.9.1",
51
+ "typescript": "^6.0.3",
52
+ "vue-tsc": "^3.3.5"
53
+ },
54
+ "license": "Apache-2.0"
55
+ }
@@ -0,0 +1,164 @@
1
+ <script setup lang="ts">
2
+ import { nextTick } from 'vue'
3
+ import type { UserContentBlock } from '@demicodes/core'
4
+ import { AddLine } from '@mingcute/vue/add'
5
+ import { SendLine } from '@mingcute/vue/send'
6
+ import { StopLine } from '@mingcute/vue/stop'
7
+ import { EditorContent } from '@tiptap/vue-3'
8
+ import { useAgentWorkspace } from './workspace'
9
+ import ModelSelector from './ModelSelector.vue'
10
+ import ContextUsageIndicator from './ContextUsageIndicator.vue'
11
+ import Tooltip from '../ui/Tooltip.vue'
12
+ import { useAgentInputActions } from './message-input/useAgentInputActions'
13
+ import { useAgentInputEditor } from './message-input/useAgentInputEditor'
14
+ import { useAgentInputSessionState } from './message-input/useAgentInputSessionState'
15
+ import { docToContent, type InputModel } from './message-input/input-model'
16
+
17
+ const props = defineProps<{
18
+ conversationId: string
19
+ }>()
20
+
21
+ const emit = defineEmits<{
22
+ 'empty-submit': []
23
+ }>()
24
+
25
+ const workspace = useAgentWorkspace()
26
+
27
+ const {
28
+ selectedProviderId,
29
+ selectedModelId,
30
+ thinkingConfig,
31
+ contextWindow,
32
+ inputLimit,
33
+ isRunning,
34
+ isCompacting,
35
+ canCompact,
36
+ usage,
37
+ } = useAgentInputSessionState(workspace, props.conversationId)
38
+
39
+ function buildSubmitPayload(): UserContentBlock[] | null {
40
+ const currentEditor = editor.value
41
+ if (!currentEditor || currentEditor.isEmpty) return null
42
+ const content = docToContent(currentEditor.getJSON() as InputModel)
43
+ return content.length > 0 ? content : null
44
+ }
45
+
46
+ function clearInput(): void {
47
+ editor.value?.commands.clearContent()
48
+ }
49
+
50
+ const { handleSubmit, handleSteerSubmit, handleQueueSubmit, handleSelectModel, handleChangeThinking, handleAbort, handleCompact } = useAgentInputActions({
51
+ workspace,
52
+ conversationId: props.conversationId,
53
+ buildSubmitPayload,
54
+ clearInput,
55
+ emitEmptySubmit() {
56
+ emit('empty-submit')
57
+ },
58
+ })
59
+
60
+ const { editor, isFocused, hasContent } = useAgentInputEditor({
61
+ handleSubmit,
62
+ handleCancel() {},
63
+ })
64
+
65
+ defineExpose({
66
+ focus() {
67
+ editor.value?.commands.focus('end', { scrollIntoView: false })
68
+ },
69
+ prefill(content: InputModel) {
70
+ editor.value?.commands.setContent(content)
71
+ nextTick(() => editor.value?.commands.focus('end', { scrollIntoView: false }))
72
+ },
73
+ })
74
+ </script>
75
+
76
+ <template>
77
+ <div>
78
+ <div
79
+ class="input-float rounded-xl outline outline-1 transition-[outline-color] duration-200"
80
+ :class="isFocused ? 'bg-surface-raised outline-line-focus' : 'bg-surface-raised outline-line'"
81
+ >
82
+ <EditorContent v-if="editor" :editor="editor" />
83
+ <div class="flex items-center justify-between px-2 pb-2">
84
+ <div class="flex items-center gap-1">
85
+ <ModelSelector
86
+ :providers="workspace.providers.value"
87
+ :models="workspace.models"
88
+ :selected-provider-id="selectedProviderId"
89
+ :selected-model-id="selectedModelId"
90
+ v-bind="thinkingConfig ? { thinkingConfig } : {}"
91
+ @select-model="handleSelectModel"
92
+ @change-thinking="handleChangeThinking"
93
+ />
94
+ </div>
95
+ <div class="flex items-center gap-1">
96
+ <ContextUsageIndicator
97
+ :conversation-id="props.conversationId"
98
+ :usage="usage"
99
+ :context-window="contextWindow"
100
+ :input-limit="inputLimit"
101
+ :is-compacting="isCompacting"
102
+ :is-clickable="!isRunning && canCompact"
103
+ @compact="handleCompact"
104
+ />
105
+ <template v-if="hasContent">
106
+ <template v-if="isRunning">
107
+ <Tooltip content="Steer current turn">
108
+ <span
109
+ class="flex size-7 cursor-pointer items-center justify-center rounded-lg bg-overlay/10 text-fg transition-colors hover:bg-active"
110
+ @click="handleSteerSubmit"
111
+ >
112
+ <SendLine :size="15" />
113
+ </span>
114
+ </Tooltip>
115
+ <Tooltip content="Queue next turn">
116
+ <span
117
+ class="flex size-7 cursor-pointer items-center justify-center rounded-lg bg-overlay/10 text-fg transition-colors hover:bg-active"
118
+ @click="handleQueueSubmit"
119
+ >
120
+ <AddLine :size="15" />
121
+ </span>
122
+ </Tooltip>
123
+ </template>
124
+ <Tooltip v-else content="Send message">
125
+ <span
126
+ class="flex size-7 cursor-pointer items-center justify-center rounded-lg bg-overlay/10 text-fg transition-colors hover:bg-active"
127
+ @click="handleSubmit"
128
+ >
129
+ <SendLine :size="15" />
130
+ </span>
131
+ </Tooltip>
132
+ </template>
133
+ <span
134
+ v-else-if="isRunning || isCompacting"
135
+ class="flex size-7 cursor-pointer items-center justify-center rounded-lg bg-overlay/10 text-fg transition-colors hover:bg-active"
136
+ @click="handleAbort"
137
+ >
138
+ <StopLine :size="15" />
139
+ </span>
140
+ <span
141
+ v-else
142
+ class="flex size-7 items-center justify-center rounded-lg text-fg-faint"
143
+ >
144
+ <SendLine :size="15" />
145
+ </span>
146
+ </div>
147
+ </div>
148
+ </div>
149
+ </div>
150
+ </template>
151
+
152
+ <style>
153
+ .input-float {
154
+ box-shadow: var(--shadow-float);
155
+ }
156
+
157
+ .tiptap p.is-editor-empty:first-child::before {
158
+ content: attr(data-placeholder);
159
+ float: left;
160
+ color: var(--color-fg-subtle);
161
+ pointer-events: none;
162
+ height: 0;
163
+ }
164
+ </style>
@@ -0,0 +1,124 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
3
+ import { useScroll } from '@vueuse/core'
4
+ import type { Block, SessionPhase } from '@demicodes/core'
5
+ import { useBlockVirtualizer, type PersistedScrollState } from '@demicodes/web-ui/composables/useBlockVirtualizer'
6
+ import { getVisibleBlocks } from './visible-blocks'
7
+ import { isThinkingBlockStreaming } from './thinking-streaming'
8
+ import { pendingSteersToRenderBlocks, type MessageListBlock } from './pending-steers'
9
+ import { shouldShowTailLoading } from './tail-loading'
10
+ import type { PendingSteerMessage } from './types'
11
+ import AgentMessageVirtualBlock from './blocks/AgentMessageVirtualBlock.vue'
12
+ import LoadingBlock from './blocks/LoadingBlock.vue'
13
+ import ScrollToBottomButton from '@demicodes/web-ui/ui/ScrollToBottomButton.vue'
14
+
15
+ const INPUT_AREA_PADDING = 48
16
+
17
+ const props = defineProps<{
18
+ conversationId: string
19
+ blocks: Block[]
20
+ pendingSteers: PendingSteerMessage[]
21
+ phase: SessionPhase
22
+ bottomOffset: number
23
+ persistedScrollState: PersistedScrollState | undefined
24
+ }>()
25
+
26
+ const emit = defineEmits<{
27
+ saveScrollState: [conversationId: string, state: PersistedScrollState | undefined]
28
+ continue: []
29
+ retry: []
30
+ deletePendingSteer: [id: string]
31
+ }>()
32
+
33
+ const visibleTranscriptBlocks = computed(() => getVisibleBlocks(props.blocks))
34
+ const renderBlocks = computed<MessageListBlock[]>(() => [
35
+ ...visibleTranscriptBlocks.value,
36
+ ...pendingSteersToRenderBlocks(props.pendingSteers),
37
+ ])
38
+
39
+ const shouldShowLoading = computed(() => shouldShowTailLoading(props.phase, visibleTranscriptBlocks.value, renderBlocks.value))
40
+
41
+ function isStreamingThinkingAt(index: number): boolean {
42
+ const block = renderBlocks.value[index]
43
+ if (!block || block.type !== 'thinking') return false
44
+ const transcriptIndex = visibleTranscriptBlocks.value.findIndex((candidate) => candidate.id === block.id)
45
+ return isThinkingBlockStreaming(visibleTranscriptBlocks.value, props.phase, transcriptIndex)
46
+ }
47
+
48
+ // The next block's createdAt marks when a thinking block stopped (null while it's still the last,
49
+ // i.e. actively thinking). Lets ThinkingBlock show a frozen "thought for Xs" that survives reload.
50
+ function thinkingEndedAt(index: number): string | null {
51
+ const block = renderBlocks.value[index]
52
+ if (!block || !('createdAt' in block)) return null
53
+ const transcriptIndex = visibleTranscriptBlocks.value.findIndex((candidate) => candidate.id === block.id)
54
+ if (transcriptIndex < 0) return null
55
+ const next = visibleTranscriptBlocks.value[transcriptIndex + 1]
56
+ return next && 'createdAt' in next ? next.createdAt : null
57
+ }
58
+
59
+ const scrollContainer = ref<HTMLDivElement>()
60
+
61
+ const { virtualItems, totalSize, measureElement, scrollOffset, isAtBottom, scrollToBottom, onScroll, getPersistedState } =
62
+ useBlockVirtualizer(scrollContainer, renderBlocks, props.persistedScrollState)
63
+
64
+ onBeforeUnmount(() => {
65
+ emit('saveScrollState', props.conversationId, getPersistedState())
66
+ })
67
+
68
+ const { isScrolling } = useScroll(scrollContainer, { idle: 1500 })
69
+
70
+ watch(
71
+ () => props.bottomOffset,
72
+ () => {
73
+ nextTick(() => {
74
+ scrollOffset.value = scrollContainer.value?.scrollTop ?? 0
75
+ })
76
+ },
77
+ { flush: 'post' },
78
+ )
79
+ </script>
80
+
81
+ <template>
82
+ <div class="relative h-full">
83
+ <div
84
+ ref="scrollContainer"
85
+ class="h-full overflow-y-auto scrollbar-hidden"
86
+ :class="isScrolling ? 'scrollbar-active' : ''"
87
+ style="overflow-anchor: none;"
88
+ @scroll="onScroll"
89
+ >
90
+ <div v-if="renderBlocks.length === 0" class="grid h-full place-items-center">
91
+ <p class="text-sm text-fg-faint">No messages yet. Start a conversation.</p>
92
+ </div>
93
+ <div
94
+ v-else
95
+ class="w-full pt-2"
96
+ :style="{ paddingBottom: `${props.bottomOffset + INPUT_AREA_PADDING}px` }"
97
+ >
98
+ <div class="relative w-full" :style="{ height: `${totalSize}px` }">
99
+ <div
100
+ v-for="item in virtualItems"
101
+ :key="String(item.key)"
102
+ :data-index="item.index"
103
+ :ref="(el) => measureElement(el as Element)"
104
+ class="absolute inset-x-0 top-0"
105
+ :style="{ transform: `translateY(${item.start}px)` }"
106
+ >
107
+ <AgentMessageVirtualBlock
108
+ :block="renderBlocks[item.index]!"
109
+ :conversation-id="props.conversationId"
110
+ :is-thinking-streaming="isStreamingThinkingAt(item.index)"
111
+ :thinking-ended-at="thinkingEndedAt(item.index)"
112
+ @continue="emit('continue')"
113
+ @retry="emit('retry')"
114
+ @delete-pending-steer="(id) => emit('deletePendingSteer', id)"
115
+ />
116
+ </div>
117
+ </div>
118
+ <LoadingBlock v-if="shouldShowLoading" />
119
+ </div>
120
+ </div>
121
+
122
+ <ScrollToBottomButton :visible="!isAtBottom" :bottom-offset="props.bottomOffset + 36" @click="scrollToBottom" />
123
+ </div>
124
+ </template>
@@ -0,0 +1,18 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { useAgentWorkspace } from './workspace'
4
+ import AgentTabBar from './AgentTabBar.vue'
5
+ import ConversationView from './ConversationView.vue'
6
+
7
+ const workspace = useAgentWorkspace()
8
+
9
+ const tabs = computed(() => workspace.tabs.value)
10
+ const activeTabId = computed(() => workspace.activeId.value)
11
+ </script>
12
+
13
+ <template>
14
+ <div class="flex h-full flex-col">
15
+ <AgentTabBar :tabs="tabs" :all-conversations="tabs" :active-tab-id="activeTabId" />
16
+ <ConversationView v-if="activeTabId" :key="activeTabId" :conversation-id="activeTabId" />
17
+ </div>
18
+ </template>
@@ -0,0 +1,17 @@
1
+ <script setup lang="ts">
2
+ import { AgentWorkspace, provideAgentWorkspace } from './workspace'
3
+ import { provideAgentUiOptions, type AgentUiOptions } from './ui-options'
4
+ import AgentPanel from './AgentPanel.vue'
5
+
6
+ const props = defineProps<{
7
+ workspace: AgentWorkspace
8
+ uiOptions?: Partial<AgentUiOptions>
9
+ }>()
10
+
11
+ provideAgentWorkspace(props.workspace)
12
+ provideAgentUiOptions(props.uiOptions)
13
+ </script>
14
+
15
+ <template>
16
+ <AgentPanel />
17
+ </template>