@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,47 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import { TerminalBoxLine } from '@mingcute/vue/terminal-box'
4
+ import AnsiText from './AnsiText.vue'
5
+ import FunctionalBlock from './FunctionalBlock.vue'
6
+ import type { ToolCallBlock } from '../block-types'
7
+ import { getToolErrorText, shellTerminalOutputChunks } from '../block-helpers'
8
+ import { standardToolTitle } from '../tool-rendering'
9
+
10
+ const props = defineProps<{
11
+ block: ToolCallBlock
12
+ input: Record<string, unknown>
13
+ isStreaming: boolean
14
+ }>()
15
+
16
+ const command = computed(() => (props.input['script'] as string) ?? '')
17
+ const title = computed(() => standardToolTitle('shell_exec', props.input))
18
+ const errorText = computed(() => getToolErrorText(props.block))
19
+ const terminalOutputText = computed(() => shellTerminalOutputChunks(props.block).map((chunk) => chunk.text).join(''))
20
+ const isOpen = ref(false)
21
+ </script>
22
+
23
+ <template>
24
+ <FunctionalBlock
25
+ v-model:open="isOpen"
26
+ :open-while="block.status === 'executing' || isStreaming"
27
+ :loading="block.status === 'executing'"
28
+ :error="block.status === 'error'"
29
+ :error-text="errorText"
30
+ :stick-bottom="block.status === 'executing' || isStreaming"
31
+ >
32
+ <template #icon>
33
+ <TerminalBoxLine :size="16" />
34
+ </template>
35
+
36
+ <template #default="{ loading }">
37
+ <span class="min-w-0 truncate" :class="loading ? 'thinking-shimmer' : ''">{{ title }}</span>
38
+ </template>
39
+
40
+ <template #body>
41
+ <div class="flex px-3 py-1 font-mono text-xs">
42
+ <span class="mr-1 shrink-0 select-none text-fg-faint">$</span><span class="min-w-0 select-all whitespace-pre-wrap break-words text-fg-muted">{{ command }}</span>
43
+ </div>
44
+ <AnsiText v-if="terminalOutputText" :content="terminalOutputText" class="px-3 pb-1" />
45
+ </template>
46
+ </FunctionalBlock>
47
+ </template>
@@ -0,0 +1,51 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { HistoryLine } from '@mingcute/vue/history'
4
+ import { TerminalBoxLine } from '@mingcute/vue/terminal-box'
5
+ import FunctionalBlock from './FunctionalBlock.vue'
6
+ import type { ToolCallBlock } from '../block-types'
7
+ import { getToolErrorText } from '../block-helpers'
8
+ import { standardToolTitle, trimToolSummary, type ControlToolName } from '../tool-rendering'
9
+
10
+ const props = defineProps<{
11
+ block: ToolCallBlock
12
+ input: Record<string, unknown>
13
+ toolName: ControlToolName
14
+ }>()
15
+
16
+ const title = computed(() => standardToolTitle(props.toolName, props.input))
17
+ const errorText = computed(() => getToolErrorText(props.block))
18
+ const errorSummary = computed(() => {
19
+ const text = errorText.value
20
+ return text ? trimToolSummary(text, 160) : ''
21
+ })
22
+ const iconComponent = computed(() => {
23
+ switch (props.toolName) {
24
+ case 'shell_status':
25
+ case 'shell_write':
26
+ case 'shell_abort':
27
+ return TerminalBoxLine
28
+ case 'yield':
29
+ return HistoryLine
30
+ }
31
+ })
32
+ </script>
33
+
34
+ <template>
35
+ <FunctionalBlock
36
+ :loading="block.status === 'executing'"
37
+ :error="block.status === 'error'"
38
+ >
39
+ <template #icon>
40
+ <component :is="iconComponent" :size="16" />
41
+ </template>
42
+
43
+ <template #default="{ loading }">
44
+ <span class="min-w-0 truncate" :class="loading ? 'thinking-shimmer' : ''">{{ title }}</span>
45
+ <span
46
+ v-if="errorSummary"
47
+ class="min-w-0 truncate font-mono text-fg-subtle"
48
+ >{{ errorSummary }}</span>
49
+ </template>
50
+ </FunctionalBlock>
51
+ </template>
@@ -0,0 +1,13 @@
1
+ <script setup lang="ts">
2
+ import type { ToolCallBlock } from '../block-types'
3
+ import ToolShellControlBlock from './ToolShellControlBlock.vue'
4
+
5
+ defineProps<{
6
+ block: ToolCallBlock
7
+ input: Record<string, unknown>
8
+ }>()
9
+ </script>
10
+
11
+ <template>
12
+ <ToolShellControlBlock :block="block" :input="input" tool-name="shell_status" />
13
+ </template>
@@ -0,0 +1,13 @@
1
+ <script setup lang="ts">
2
+ import type { ToolCallBlock } from '../block-types'
3
+ import ToolShellControlBlock from './ToolShellControlBlock.vue'
4
+
5
+ defineProps<{
6
+ block: ToolCallBlock
7
+ input: Record<string, unknown>
8
+ }>()
9
+ </script>
10
+
11
+ <template>
12
+ <ToolShellControlBlock :block="block" :input="input" tool-name="shell_write" />
13
+ </template>
@@ -0,0 +1,13 @@
1
+ <script setup lang="ts">
2
+ import { t } from '@demicodes/web-ui/infra/i18n'
3
+
4
+ defineProps<{
5
+ status: 'error'
6
+ }>()
7
+ </script>
8
+
9
+ <template>
10
+ <span class="rounded bg-tint-danger-strong px-1.5 py-1 text-[10px] font-medium uppercase tracking-wide text-on-danger">
11
+ {{ t('agent.block.error') }}
12
+ </span>
13
+ </template>
@@ -0,0 +1,13 @@
1
+ <script setup lang="ts">
2
+ import type { ToolCallBlock } from '../block-types'
3
+ import ToolShellControlBlock from './ToolShellControlBlock.vue'
4
+
5
+ defineProps<{
6
+ block: ToolCallBlock
7
+ input: Record<string, unknown>
8
+ }>()
9
+ </script>
10
+
11
+ <template>
12
+ <ToolShellControlBlock :block="block" :input="input" tool-name="yield" />
13
+ </template>
@@ -0,0 +1,112 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import { useResizeObserver } from '@vueuse/core'
4
+ import { CloseLine } from '@mingcute/vue/close'
5
+ import { FileLine } from '@mingcute/vue/file'
6
+ import type { UserContentBlock } from '@demicodes/core'
7
+ import { md } from '@demicodes/web-ui/markdown/md'
8
+
9
+ type ImageBlock = Extract<UserContentBlock, { type: 'image' }>
10
+
11
+ const props = defineProps<{
12
+ content: UserContentBlock[]
13
+ forceStuck?: boolean
14
+ variant?: 'user' | 'steer'
15
+ pending?: boolean
16
+ deletable?: boolean
17
+ }>()
18
+
19
+ const emit = defineEmits<{
20
+ delete: []
21
+ }>()
22
+
23
+ const userText = computed(() => {
24
+ const firstText = props.content.find(
25
+ (b): b is Extract<UserContentBlock, { type: 'text' }> => b.type === 'text',
26
+ )
27
+ return firstText?.text ?? ''
28
+ })
29
+
30
+ const imageBlocks = computed(() =>
31
+ props.content.filter((b): b is ImageBlock => b.type === 'image'),
32
+ )
33
+
34
+ const documentBlocks = computed(() =>
35
+ props.content.filter((b): b is Extract<UserContentBlock, { type: 'document' }> => b.type === 'document'),
36
+ )
37
+
38
+ function imageSrc(source: ImageBlock['source']): string {
39
+ if (source.type === 'url') return source.url
40
+ return URL.createObjectURL(new Blob([source.data as BlobPart], { type: source.mediaType }))
41
+ }
42
+
43
+ const renderedMarkdown = computed(() => md.renderUser(userText.value))
44
+
45
+ const contentRef = ref<HTMLElement>()
46
+ const isOverflowing = ref(false)
47
+
48
+ useResizeObserver(contentRef, () => {
49
+ if (!contentRef.value) return
50
+ isOverflowing.value = contentRef.value.scrollHeight > contentRef.value.clientHeight
51
+ })
52
+ </script>
53
+
54
+ <template>
55
+ <div
56
+ class="relative z-10 flex flex-col items-end bg-surface px-[var(--agent-pad-x,2rem)] pb-2 pt-1.5"
57
+ :class="forceStuck ? 'user-sticky' : ''"
58
+ >
59
+ <div
60
+ class="group/user relative max-w-[80%] rounded-xl p-3"
61
+ :class="props.variant === 'steer' ? 'bg-surface ring-1 ring-line-focus' : 'bg-surface-raised'"
62
+ >
63
+ <button
64
+ v-if="deletable"
65
+ type="button"
66
+ aria-label="Delete pending steer"
67
+ class="absolute left-0 top-1/2 flex size-5 -translate-x-[calc(100%+6px)] -translate-y-1/2 cursor-pointer items-center justify-center rounded text-fg-ghost transition-colors hover:bg-active hover:text-fg-muted group-hover/user:text-fg-subtle"
68
+ @click.stop="emit('delete')"
69
+ >
70
+ <CloseLine :size="13" />
71
+ </button>
72
+ <div :class="pending ? 'opacity-50' : ''">
73
+ <div v-if="imageBlocks.length > 0 || documentBlocks.length > 0" class="mb-2 flex flex-wrap gap-1.5">
74
+ <img
75
+ v-for="(block, i) in imageBlocks"
76
+ :key="`img-${i}`"
77
+ :src="imageSrc(block.source)"
78
+ class="size-12 rounded-lg object-cover ring-1 ring-line"
79
+ />
80
+ <span
81
+ v-for="(block, i) in documentBlocks"
82
+ :key="`doc-${i}`"
83
+ class="flex size-12 items-center justify-center rounded-lg bg-fg-ghost/60 text-fg-muted ring-1 ring-line"
84
+ :title="block.source.fileName"
85
+ >
86
+ <FileLine :size="18" />
87
+ </span>
88
+ </div>
89
+ <div
90
+ ref="contentRef"
91
+ class="max-h-48 overflow-hidden"
92
+ :style="isOverflowing ? { maskImage: 'linear-gradient(to bottom, black calc(100% - 3rem), transparent)' } : undefined"
93
+ >
94
+ <div v-if="userText" class="markdown-body select-text text-sm leading-relaxed text-fg-body" v-html="renderedMarkdown" />
95
+ </div>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ </template>
100
+
101
+ <style scoped>
102
+ .user-sticky::after {
103
+ content: '';
104
+ position: absolute;
105
+ bottom: -32px;
106
+ inset-inline: 0;
107
+ height: 33px;
108
+ background: linear-gradient(to bottom, var(--color-surface), transparent);
109
+ pointer-events: none;
110
+ }
111
+
112
+ </style>
@@ -0,0 +1,205 @@
1
+ import type { AgentClient, ClientSessionEvent } from '@demicodes/agent/client'
2
+ import type { Block, UserContentBlock } from '@demicodes/core'
3
+ import { agentSocketUrl, connectAgentClient } from '../transport/agent-socket'
4
+ import type { ControlApi } from '../transport/protocol'
5
+ import type { ConversationState } from './types'
6
+ import { createPendingSteerMessage, reconcilePendingSteers } from './pending-steers'
7
+
8
+ /**
9
+ * Non-reactive owner of one conversation's AgentClient. Lazily connects the
10
+ * per-session WebSocket on first action and writes incoming events into the
11
+ * reactive ConversationState.
12
+ */
13
+ export class ConversationRuntime {
14
+ private client: AgentClient | null = null
15
+ private opening: Promise<AgentClient> | null = null
16
+ private unsubscribe: (() => void) | null = null
17
+ private readonly canceledPendingSteers = new Set<string>()
18
+ private readonly activePendingSteerRequests = new Set<string>()
19
+
20
+ constructor(
21
+ private readonly state: ConversationState,
22
+ private readonly baseUrl: string,
23
+ private readonly control: ControlApi,
24
+ ) {}
25
+
26
+ async send(content: UserContentBlock[]): Promise<void> {
27
+ const client = await this.ensureOpen()
28
+ this.state.isResultSeen = true
29
+ await client.send(content)
30
+ }
31
+
32
+ dequeueMessage(messageId: string): void {
33
+ this.client?.dequeueMessage(messageId)
34
+ }
35
+
36
+ sendQueuedMessage(messageId: string): void {
37
+ this.client?.sendQueuedMessage(messageId)
38
+ }
39
+
40
+ async steerQueuedMessage(messageId: string): Promise<void> {
41
+ const queued = this.state.queue.find((message) => message.id === messageId)
42
+ if (!queued) return
43
+
44
+ const steerId = globalThis.crypto.randomUUID()
45
+ const pending = createPendingSteerMessage(steerId, queued.content, this.state.blocks)
46
+ this.activePendingSteerRequests.add(steerId)
47
+ this.state.pendingSteers = [...this.state.pendingSteers, pending]
48
+ try {
49
+ const client = await this.ensureOpen()
50
+ if (this.canceledPendingSteers.has(steerId)) return
51
+ this.state.isResultSeen = true
52
+ await client.steerQueuedMessage(messageId, { steerId })
53
+ if (this.canceledPendingSteers.has(steerId)) client.cancelPendingSteer(steerId)
54
+ } catch (error) {
55
+ this.removePendingSteer(pending.id)
56
+ if (this.canceledPendingSteers.has(steerId)) return
57
+ throw error
58
+ } finally {
59
+ this.activePendingSteerRequests.delete(steerId)
60
+ if (!this.state.pendingSteers.some((candidate) => candidate.id === steerId)) {
61
+ this.canceledPendingSteers.delete(steerId)
62
+ }
63
+ }
64
+ }
65
+
66
+ clearMessageQueue(messageIds: string[]): void {
67
+ this.client?.clearMessageQueue(messageIds)
68
+ }
69
+
70
+ async steer(content: UserContentBlock[]): Promise<void> {
71
+ const steerId = globalThis.crypto.randomUUID()
72
+ const pending = createPendingSteerMessage(steerId, content, this.state.blocks)
73
+ this.activePendingSteerRequests.add(steerId)
74
+ this.state.pendingSteers = [...this.state.pendingSteers, pending]
75
+ try {
76
+ const client = await this.ensureOpen()
77
+ if (this.canceledPendingSteers.has(steerId)) return
78
+ this.state.isResultSeen = true
79
+ await client.steer(content, { steerId })
80
+ if (this.canceledPendingSteers.has(steerId)) client.cancelPendingSteer(steerId)
81
+ } catch (error) {
82
+ this.removePendingSteer(pending.id)
83
+ if (this.canceledPendingSteers.has(steerId)) return
84
+ throw error
85
+ } finally {
86
+ this.activePendingSteerRequests.delete(steerId)
87
+ if (!this.state.pendingSteers.some((candidate) => candidate.id === steerId)) {
88
+ this.canceledPendingSteers.delete(steerId)
89
+ }
90
+ }
91
+ }
92
+
93
+ deletePendingSteer(id: string): void {
94
+ this.canceledPendingSteers.add(id)
95
+ this.removePendingSteer(id)
96
+ this.client?.cancelPendingSteer(id)
97
+ if (!this.activePendingSteerRequests.has(id)) this.canceledPendingSteers.delete(id)
98
+ }
99
+
100
+ /**
101
+ * Pushes a model/provider switch to an already-open session so the next turn uses it. If the
102
+ * session has not been opened yet, this is a no-op: openSession reads the latest state.model.
103
+ */
104
+ async setModel(): Promise<void> {
105
+ if (!this.client) return
106
+ const intent = this.state.model
107
+ const providerConfig = await this.control.prepareSession({
108
+ providerId: intent.providerId,
109
+ modelId: intent.modelId,
110
+ thinkingEffort: intent.thinkingEffort,
111
+ serviceTierId: intent.serviceTierId,
112
+ })
113
+ this.client.setProvider(providerConfig)
114
+ }
115
+
116
+ async abort(): Promise<void> {
117
+ await this.client?.abort()
118
+ }
119
+
120
+ async retry(): Promise<void> {
121
+ await (await this.ensureOpen()).retry()
122
+ }
123
+
124
+ async resume(): Promise<void> {
125
+ await (await this.ensureOpen()).resume()
126
+ }
127
+
128
+ async compact(): Promise<void> {
129
+ await (await this.ensureOpen()).compact()
130
+ }
131
+
132
+ async dispose(): Promise<void> {
133
+ this.unsubscribe?.()
134
+ this.unsubscribe = null
135
+ const client = this.client
136
+ this.client = null
137
+ this.opening = null
138
+ if (client) await client.close().catch(() => {})
139
+ }
140
+
141
+ // Open the connection now (without sending), so a restored conversation
142
+ // fetches its transcript snapshot and is ready to use.
143
+ connect(): Promise<void> {
144
+ return this.ensureOpen().then(() => undefined)
145
+ }
146
+
147
+ private ensureOpen(): Promise<AgentClient> {
148
+ if (this.client) return Promise.resolve(this.client)
149
+ this.opening ??= this.openSession()
150
+ return this.opening
151
+ }
152
+
153
+ private async openSession(): Promise<AgentClient> {
154
+ const client = await connectAgentClient(agentSocketUrl(this.baseUrl, this.state.cwd))
155
+ this.unsubscribe = client.subscribe((event) => this.applyEvent(event))
156
+ const intent = this.state.model
157
+ const providerConfig = await this.control.prepareSession({
158
+ providerId: intent.providerId,
159
+ modelId: intent.modelId,
160
+ thinkingEffort: intent.thinkingEffort,
161
+ serviceTierId: intent.serviceTierId,
162
+ })
163
+ // The conversation id is the stable session id — reconnecting with it
164
+ // resumes this conversation's transcript instead of starting blank.
165
+ await client.open(providerConfig, this.state.cwd, this.state.id)
166
+ this.client = client
167
+ return client
168
+ }
169
+
170
+ private applyEvent(event: ClientSessionEvent): void {
171
+ switch (event.type) {
172
+ case 'transcript_reset':
173
+ case 'transcript_patch':
174
+ this.applyTranscriptBlocks(event.blocks)
175
+ return
176
+ case 'phase':
177
+ this.state.phase = event.phase
178
+ if (event.phase === 'idle' && this.state.pendingSteers.length > 0) {
179
+ this.state.pendingSteers = reconcilePendingSteers(this.state.blocks, this.state.pendingSteers)
180
+ if (this.state.pendingSteers.length > 0) this.state.pendingSteers = []
181
+ }
182
+ return
183
+ case 'queue':
184
+ this.state.queue = event.queue
185
+ return
186
+ case 'error':
187
+ this.state.lastError = event.message
188
+ return
189
+ case 'closed':
190
+ this.client = null
191
+ this.opening = null
192
+ return
193
+ }
194
+ }
195
+
196
+ private applyTranscriptBlocks(blocks: Block[]): void {
197
+ this.state.blocks = blocks
198
+ this.state.hasContent = blocks.length > 0
199
+ this.state.pendingSteers = reconcilePendingSteers(blocks, this.state.pendingSteers)
200
+ }
201
+
202
+ private removePendingSteer(id: string): void {
203
+ this.state.pendingSteers = this.state.pendingSteers.filter((pending) => pending.id !== id)
204
+ }
205
+ }
@@ -0,0 +1,12 @@
1
+ import type { ConversationState } from './types'
2
+
3
+ export type ConversationStatus = 'idle' | 'active' | 'done' | 'error' | 'aborted'
4
+
5
+ export function conversationStatus(state: ConversationState): ConversationStatus {
6
+ if (state.phase === 'running' || state.phase === 'compacting') return 'active'
7
+ if (state.lastError) return 'error'
8
+ const last = state.blocks[state.blocks.length - 1]
9
+ if (last?.type === 'abort') return 'aborted'
10
+ if (!state.isResultSeen && state.blocks.length > 0) return 'done'
11
+ return 'idle'
12
+ }
@@ -0,0 +1,33 @@
1
+ import type { UserContentBlock } from '@demicodes/core'
2
+
3
+ // The tiptap document the composer edits. With mentions/slash removed it only ever
4
+ // contains paragraphs of text, so it maps directly to demi text content blocks.
5
+
6
+ export interface InputTextNode {
7
+ type: 'text'
8
+ text: string
9
+ }
10
+
11
+ export interface InputParagraph {
12
+ type: 'paragraph'
13
+ content?: InputTextNode[]
14
+ }
15
+
16
+ export interface InputModel {
17
+ type: 'doc'
18
+ content: InputParagraph[]
19
+ }
20
+
21
+ export function docToText(model: InputModel | null | undefined): string {
22
+ if (!model) return ''
23
+ return model.content
24
+ .map((paragraph) => (paragraph.content ?? []).map((node) => node.text).join(''))
25
+ .join('\n')
26
+ .trim()
27
+ }
28
+
29
+ export function docToContent(model: InputModel | null | undefined, attachments: UserContentBlock[] = []): UserContentBlock[] {
30
+ const text = docToText(model)
31
+ const blocks: UserContentBlock[] = text ? [{ type: 'text', text }] : []
32
+ return [...blocks, ...attachments]
33
+ }
@@ -0,0 +1,96 @@
1
+ import type { ThinkingConfig, UserContentBlock } from '@demicodes/core'
2
+ import { reportError } from '@demicodes/web-ui/infra/errors'
3
+ import type { AgentWorkspace } from '../workspace'
4
+ import { thinkingConfigToEffort } from '../reasoning'
5
+ import { queuedMessageIdForEmptySubmit } from '../queue-submit'
6
+
7
+ interface UseAgentInputActionsParams {
8
+ workspace: AgentWorkspace
9
+ conversationId: string
10
+ buildSubmitPayload: () => UserContentBlock[] | null
11
+ clearInput: () => void
12
+ emitEmptySubmit?: () => void
13
+ }
14
+
15
+ export function useAgentInputActions(params: UseAgentInputActionsParams) {
16
+ async function handleSubmit(): Promise<void> {
17
+ const content = params.buildSubmitPayload()
18
+ if (!content) {
19
+ const session = params.workspace.sessions[params.conversationId]
20
+ const queue = session?.queue ?? []
21
+ const messageId = queuedMessageIdForEmptySubmit(queue)
22
+ if (messageId) {
23
+ if (session?.phase === 'running') {
24
+ try {
25
+ await params.workspace.steerQueuedMessage(params.conversationId, messageId)
26
+ } catch (error) {
27
+ reportError('Failed to steer queued message', error, { userVisible: true })
28
+ }
29
+ } else {
30
+ params.workspace.sendQueuedMessage(params.conversationId, messageId)
31
+ }
32
+ return
33
+ }
34
+ params.emitEmptySubmit?.()
35
+ return
36
+ }
37
+ params.clearInput()
38
+ try {
39
+ await params.workspace.send(params.conversationId, content)
40
+ } catch (error) {
41
+ reportError('Failed to send message', error, { userVisible: true })
42
+ }
43
+ }
44
+
45
+ async function handleSteerSubmit(): Promise<void> {
46
+ const content = params.buildSubmitPayload()
47
+ if (!content) return
48
+ params.clearInput()
49
+ try {
50
+ await params.workspace.steer(params.conversationId, content)
51
+ } catch (error) {
52
+ reportError('Failed to steer turn', error, { userVisible: true })
53
+ }
54
+ }
55
+
56
+ async function handleQueueSubmit(): Promise<void> {
57
+ const content = params.buildSubmitPayload()
58
+ if (!content) return
59
+ params.clearInput()
60
+ try {
61
+ await params.workspace.send(params.conversationId, content)
62
+ } catch (error) {
63
+ reportError('Failed to queue message', error, { userVisible: true })
64
+ }
65
+ }
66
+
67
+ function handleSelectModel(providerId: string, modelId: string): void {
68
+ const current = params.workspace.sessions[params.conversationId]?.model
69
+ params.workspace.setModel(params.conversationId, {
70
+ providerId,
71
+ modelId,
72
+ thinkingEffort: current?.thinkingEffort ?? null,
73
+ serviceTierId: null,
74
+ })
75
+ }
76
+
77
+ function handleChangeThinking(config: ThinkingConfig): void {
78
+ const current = params.workspace.sessions[params.conversationId]?.model
79
+ if (!current) return
80
+ params.workspace.setModel(params.conversationId, { ...current, thinkingEffort: thinkingConfigToEffort(config) })
81
+ }
82
+
83
+ function handleAbort(): void {
84
+ void params.workspace.abort(params.conversationId).catch((error) => {
85
+ reportError('Failed to abort conversation', error, { userVisible: true })
86
+ })
87
+ }
88
+
89
+ function handleCompact(): void {
90
+ void params.workspace.compact(params.conversationId).catch(() => {
91
+ // phase stream carries failure/abort status
92
+ })
93
+ }
94
+
95
+ return { handleSubmit, handleSteerSubmit, handleQueueSubmit, handleSelectModel, handleChangeThinking, handleAbort, handleCompact }
96
+ }