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