subagent thinking accordion; indicator fixes; script details & edit

This commit is contained in:
2026-04-20 15:09:37 -04:00
parent a188268906
commit b1d4459809
25 changed files with 2041 additions and 174 deletions

View File

@@ -8,6 +8,8 @@ import TabPanel from 'primevue/tabpanel'
import OrdersTab from './tabs/OrdersTab.vue'
import PlaceholderTab from './tabs/PlaceholderTab.vue'
import ResearchTab from './tabs/ResearchTab.vue'
import StrategiesTab from './tabs/StrategiesTab.vue'
import IndicatorsTab from './tabs/IndicatorsTab.vue'
interface TempTab {
id: string
@@ -84,10 +86,11 @@ defineExpose({
<div v-if="isExpanded" class="tray-resize-handle" @pointerdown="startResize" @pointermove="onResizeMove" />
<Tabs :value="isExpanded ? activeTab : null" class="tray-tabs">
<TabList class="tray-tab-list">
<Tab value="positions" @click="onTabClick('positions')">Positions</Tab>
<Tab value="orders" @click="onTabClick('orders')">Orders</Tab>
<Tab value="indicators" @click="onTabClick('indicators')">Indicators</Tab>
<Tab value="research" @click="onTabClick('research')">Research</Tab>
<Tab value="strategies" @click="onTabClick('strategies')">Strategies</Tab>
<Tab value="positions" @click="onTabClick('positions')">Positions</Tab>
<Tab
v-for="tab in tempTabs"
:key="tab.id"
@@ -106,8 +109,9 @@ defineExpose({
<TabPanels v-if="isExpanded" class="tray-panels">
<TabPanel value="positions" class="tray-panel"><PlaceholderTab label="Positions" /></TabPanel>
<TabPanel value="orders" class="tray-panel"><OrdersTab /></TabPanel>
<TabPanel value="strategies" class="tray-panel"><PlaceholderTab label="Strategies" /></TabPanel>
<TabPanel value="research" class="tray-panel"><ResearchTab /></TabPanel>
<TabPanel value="indicators" class="tray-panel"><IndicatorsTab /></TabPanel>
<TabPanel value="strategies" class="tray-panel"><StrategiesTab /></TabPanel>
<TabPanel
v-for="tab in tempTabs"
:key="tab.id"

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import { ref } from 'vue'
import DetailsEditDialog from './DetailsEditDialog.vue'
const props = defineProps<{
category: 'indicator' | 'strategy' | 'research'
rows: Array<{ id: string; display_name: string; description?: string }>
}>()
const dialogVisible = ref(false)
const editingName = ref('')
function openEdit(name: string) {
editingName.value = name
dialogVisible.value = true
}
function onUpdated(_payload: { category: string; name: string; success: boolean; error?: string }) {
// Hook for handling the details_updated response — add logic here as needed
}
</script>
<template>
<div class="category-list">
<div v-if="rows.length === 0" class="empty">No items</div>
<div v-for="row in rows" :key="row.id" class="item-row">
<span class="item-name">{{ row.display_name }}</span>
<span class="item-desc">{{ row.description ?? '' }}</span>
<button class="edit-btn" @click="openEdit(row.display_name)">Edit</button>
</div>
</div>
<DetailsEditDialog
v-model:visible="dialogVisible"
:category="category"
:name="editingName"
@updated="onUpdated"
/>
</template>
<style scoped>
.category-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.empty {
color: #555;
text-align: center;
padding: 16px;
font-size: 12px;
}
.item-row {
display: flex;
align-items: center;
gap: 10px;
padding: 5px 10px;
border-bottom: 1px solid #1e1e1e;
min-width: 0;
}
.item-row:hover {
background: #1a1a1a;
}
.item-name {
font-size: 12px;
font-weight: 600;
color: #dbdbdb;
white-space: nowrap;
flex-shrink: 0;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
}
.item-desc {
flex: 1;
min-width: 0;
font-size: 12px;
color: #777;
white-space: nowrap;
overflow: hidden;
-webkit-mask-image: linear-gradient(to right, black calc(100% - 48px), transparent 100%);
mask-image: linear-gradient(to right, black calc(100% - 48px), transparent 100%);
}
.edit-btn {
flex-shrink: 0;
background: none;
border: 1px solid #3d3d3d;
color: #888;
cursor: pointer;
font-size: 11px;
padding: 2px 8px;
border-radius: 3px;
line-height: 18px;
}
.edit-btn:hover {
border-color: #089981;
color: #089981;
}
</style>

View File

@@ -32,19 +32,10 @@ let symbolWatcher: WatchStopHandle | null = null
const maybeInitChart = () => {
if (chartInitialized || !chartContainer.value) return
if (!chartStore.symbol) {
// Defer until backend provides a symbol
if (!symbolWatcher) {
symbolWatcher = watch(() => chartStore.symbol, (sym) => {
if (sym) {
symbolWatcher?.()
symbolWatcher = null
maybeInitChart()
}
})
}
return
}
// If the workspace persisted null values (bad state), reset to defaults immediately
// rather than deferring forever. This also syncs the reset back to the container.
if (!chartStore.symbol) chartStore.symbol = 'BTC/USDT.BINANCE'
if (!chartStore.period) chartStore.period = 900
chartInitialized = true
initChart()
}
@@ -73,7 +64,7 @@ function initChart() {
tvWidget = new window.TradingView.widget({
symbol: chartStore.symbol, // Use symbol from store
datafeed: datafeed,
interval: secondsToInterval(chartStore.period) as any,
interval: secondsToInterval(chartStore.period || 900) as any,
container: chartContainer.value!,
library_path: '/charting_library/',
locale: 'en',

View File

@@ -5,6 +5,7 @@ import Badge from 'primevue/badge'
import { wsManager } from '../composables/useWebSocket'
import type { WebSocketMessage } from '../composables/useWebSocket'
import { useChannelStore } from '../stores/channel'
import ToolCallWithSubagents from './ToolCallWithSubagents.vue'
register()
@@ -57,6 +58,7 @@ const rooms = computed(() => [{
let currentStreamingMessageId: string | null = null
let toolCallMessageId: string | null = null
let lastSentMessageId: string | null = null
let subagentContentMap = new Map<string, number>() // agentName → index in subagentBlocks
let streamingBuffer = ''
const isAgentProcessing = ref(false)
const isStopHovered = ref(false)
@@ -64,6 +66,7 @@ const isStopPressed = ref(false)
const addToolCallBubble = (label: string) => {
removeToolCallBubble()
subagentContentMap = new Map()
toolCallMessageId = `tool-call-${Date.now()}`
const timestamp = new Date().toTimeString().split(' ')[0].slice(0, 5)
messages.value = [...messages.value, {
@@ -76,7 +79,9 @@ const addToolCallBubble = (label: string) => {
distributed: false,
seen: false,
files: [],
toolCall: true
toolCall: true,
subagentBlocks: [],
collapsed: false
}]
}
@@ -92,10 +97,49 @@ const appendToolCallStatus = (status: string) => {
}
}
const removeToolCallBubble = () => {
if (toolCallMessageId) {
messages.value = messages.value.filter(m => m._id !== toolCallMessageId)
toolCallMessageId = null
const removeToolCallBubble = (force = false) => {
if (!toolCallMessageId) return
if (!force) {
const idx = messages.value.findIndex(m => m._id === toolCallMessageId)
if (idx !== -1 && messages.value[idx].subagentBlocks?.length) {
// Has subagent content — fold and keep for the user to re-expand
messages.value[idx] = {
...messages.value[idx],
collapsed: true,
subagentBlocks: messages.value[idx].subagentBlocks.map((b: any) => ({ ...b, isActive: false }))
}
messages.value = [...messages.value]
toolCallMessageId = null
subagentContentMap = new Map()
return
}
}
messages.value = messages.value.filter(m => m._id !== toolCallMessageId)
toolCallMessageId = null
subagentContentMap = new Map()
}
const appendSubagentChunk = (agentName: string, content: string) => {
if (!toolCallMessageId) return
const idx = messages.value.findIndex(m => m._id === toolCallMessageId)
if (idx === -1) return
const blocks = [...(messages.value[idx].subagentBlocks ?? [])]
if (subagentContentMap.has(agentName)) {
const blockIdx = subagentContentMap.get(agentName)!
blocks[blockIdx] = { ...blocks[blockIdx], content: blocks[blockIdx].content + content }
} else {
subagentContentMap.set(agentName, blocks.length)
blocks.push({ agentName, content, isActive: true })
}
messages.value[idx] = { ...messages.value[idx], subagentBlocks: blocks }
messages.value = [...messages.value]
}
const toggleCollapsed = (messageId: string) => {
const idx = messages.value.findIndex(m => m._id === messageId)
if (idx !== -1) {
messages.value[idx] = { ...messages.value[idx], collapsed: !messages.value[idx].collapsed }
messages.value = [...messages.value]
}
}
@@ -146,7 +190,7 @@ const handleMessage = (data: WebSocketMessage) => {
}
if (data.type === 'subagent_chunk') {
// Subagent final text — not shown separately; the main agent will incorporate it in its response
appendSubagentChunk(data.agentName, data.content)
return
}
@@ -189,13 +233,12 @@ const handleMessage = (data: WebSocketMessage) => {
}
} else if (data.type === 'agent_chunk') {
console.log('[ChatPanel] Processing agent_chunk, content:', data.content, 'done:', data.done)
// Always remove any tool-call bubble when the agent sends text, whether this
// is a new message or a continuation of an existing one (e.g. after a retry).
removeToolCallBubble()
const timestamp = new Date().toTimeString().split(' ')[0].slice(0, 5)
if (!currentStreamingMessageId) {
console.log('[ChatPanel] Starting new streaming message')
// Fold tool call bubble (keeps it if it has subagent content, removes if empty)
removeToolCallBubble()
// Set up streaming state and mark user message as seen
isAgentProcessing.value = true
currentStreamingMessageId = generateMessageId()
@@ -310,7 +353,7 @@ const handleMessage = (data: WebSocketMessage) => {
const stopAgent = () => {
wsManager.send({ type: 'agent_stop', session_id: SESSION_ID })
isAgentProcessing.value = false
removeToolCallBubble()
removeToolCallBubble(true)
lastSentMessageId = null
}
@@ -651,6 +694,14 @@ onUnmounted(() => {
@fetch-messages="fetchMessages"
@open-file="openFile"
>
<div
v-for="msg in messages.filter((m: any) => m.toolCall && m.subagentBlocks?.length)"
:key="'slot-' + msg._id"
:slot="'message_' + msg._id"
>
<ToolCallWithSubagents :message="msg" @toggle="toggleCollapsed(msg._id)" />
</div>
<div
v-if="isAgentProcessing"
slot="send-icon"

View File

@@ -0,0 +1,294 @@
<script setup lang="ts">
import { ref, watch, computed, onUnmounted } from 'vue'
import Dialog from 'primevue/dialog'
import Button from 'primevue/button'
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import { Markdown } from 'tiptap-markdown'
import { wsManager, type WebSocketMessage } from '../composables/useWebSocket'
const props = defineProps<{
visible: boolean
category: 'indicator' | 'strategy' | 'research'
name: string
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
'updated': [payload: { category: string; name: string; success: boolean; error?: string }]
}>()
type LoadState = 'idle' | 'loading' | 'ready' | 'error'
const loadState = ref<LoadState>('idle')
const loadError = ref('')
const saving = ref(false)
const showConfirmCancel = ref(false)
const editor = useEditor({
extensions: [
StarterKit,
Markdown.configure({ html: false, transformPastedText: true }),
],
content: '',
})
const originalContent = ref('')
const isDirty = computed(() => {
if (!editor.value || loadState.value !== 'ready') return false
return (editor.value.storage as any).markdown.getMarkdown() !== originalContent.value
})
watch(() => props.visible, (v) => {
if (v) {
loadState.value = 'loading'
loadError.value = ''
saving.value = false
showConfirmCancel.value = false
wsManager.send({ type: 'read_details', category: props.category, name: props.name })
} else {
loadState.value = 'idle'
originalContent.value = ''
editor.value?.commands.setContent('')
}
})
const messageHandler = (msg: WebSocketMessage) => {
if (msg.category !== props.category || msg.name !== props.name) return
if (msg.type === 'details_data') {
originalContent.value = msg.details ?? ''
editor.value?.commands.setContent(msg.details ?? '')
loadState.value = 'ready'
} else if (msg.type === 'details_error') {
loadError.value = msg.error ?? 'Failed to load details'
loadState.value = 'error'
} else if (msg.type === 'details_updated') {
saving.value = false
emit('updated', {
category: props.category,
name: props.name,
success: msg.success,
error: msg.error,
})
if (msg.success) {
close()
} else {
loadError.value = msg.error ?? 'Update failed'
}
}
}
wsManager.addHandler(messageHandler)
onUnmounted(() => wsManager.removeHandler(messageHandler))
function save() {
if (!editor.value || saving.value) return
saving.value = true
loadError.value = ''
wsManager.send({
type: 'update_details',
category: props.category,
name: props.name,
details: (editor.value.storage as any).markdown.getMarkdown(),
})
}
function requestClose() {
if (isDirty.value) {
showConfirmCancel.value = true
} else {
close()
}
}
function close() {
showConfirmCancel.value = false
emit('update:visible', false)
}
</script>
<template>
<Dialog
:visible="visible"
:header="`Edit Details — ${name}`"
:modal="true"
:closable="true"
:style="{ width: '720px', maxWidth: '95vw' }"
class="details-dialog"
@update:visible="requestClose"
>
<div class="dialog-body">
<div v-if="loadState === 'loading'" class="state-msg">
<i class="pi pi-spin pi-spinner" /> Loading details
</div>
<div v-else-if="loadState === 'error'" class="state-msg error">
<i class="pi pi-exclamation-triangle" /> {{ loadError }}
</div>
<template v-else>
<div v-if="loadError" class="save-error">
<i class="pi pi-exclamation-triangle" /> {{ loadError }}
</div>
<div class="editor-wrap">
<EditorContent :editor="editor" class="tiptap-editor" />
</div>
</template>
</div>
<!-- Confirm-cancel overlay -->
<div v-if="showConfirmCancel" class="confirm-overlay">
<div class="confirm-box">
<p>Discard unsaved changes?</p>
<div class="confirm-actions">
<Button label="Keep editing" size="small" outlined @click="showConfirmCancel = false" />
<Button label="Discard" size="small" severity="danger" @click="close" />
</div>
</div>
</div>
<template #footer>
<Button label="Cancel" size="small" outlined :disabled="saving" @click="requestClose" />
<Button
label="Save"
size="small"
:loading="saving"
:disabled="loadState !== 'ready'"
@click="save"
/>
</template>
</Dialog>
</template>
<style scoped>
.dialog-body {
min-height: 300px;
display: flex;
flex-direction: column;
position: relative;
}
.state-msg {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: #8a8a8a;
font-size: 13px;
}
.state-msg.error {
color: #e06c6c;
}
.save-error {
color: #e06c6c;
font-size: 12px;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.editor-wrap {
flex: 1;
border: 1px solid #2e2e2e;
border-radius: 4px;
overflow: auto;
background: #0d0d0d;
min-height: 300px;
}
.confirm-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
z-index: 1;
}
.confirm-box {
background: #1a1a1a;
border: 1px solid #3d3d3d;
border-radius: 6px;
padding: 20px 24px;
text-align: center;
display: flex;
flex-direction: column;
gap: 14px;
}
.confirm-box p {
color: #dbdbdb;
font-size: 13px;
margin: 0;
}
.confirm-actions {
display: flex;
gap: 8px;
justify-content: center;
}
</style>
<style>
/* Global (unscoped) so TipTap's .ProseMirror gets styled */
.details-dialog .tiptap-editor .ProseMirror {
padding: 12px 14px;
outline: none;
min-height: 280px;
font-size: 13px;
line-height: 1.6;
color: #dbdbdb;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
}
.details-dialog .tiptap-editor .ProseMirror h1,
.details-dialog .tiptap-editor .ProseMirror h2,
.details-dialog .tiptap-editor .ProseMirror h3 {
color: #dbdbdb;
margin: 0.8em 0 0.4em;
}
.details-dialog .tiptap-editor .ProseMirror p { margin: 0 0 0.6em; }
.details-dialog .tiptap-editor .ProseMirror code {
background: #1a1a1a;
border-radius: 3px;
padding: 1px 5px;
font-size: 0.9em;
color: #89d4e0;
}
.details-dialog .tiptap-editor .ProseMirror pre {
background: #141414;
border: 1px solid #2e2e2e;
border-radius: 4px;
padding: 10px 14px;
overflow-x: auto;
margin: 0.6em 0;
}
.details-dialog .tiptap-editor .ProseMirror pre code {
background: none;
padding: 0;
color: #dbdbdb;
}
.details-dialog .tiptap-editor .ProseMirror ul,
.details-dialog .tiptap-editor .ProseMirror ol {
padding-left: 1.4em;
margin: 0.4em 0;
}
.details-dialog .tiptap-editor .ProseMirror blockquote {
border-left: 3px solid #3d3d3d;
margin: 0.6em 0;
padding-left: 12px;
color: #8a8a8a;
}
</style>

View File

@@ -0,0 +1,152 @@
<script setup lang="ts">
import { computed } from 'vue'
interface SubagentBlock {
agentName: string
content: string
isActive: boolean
}
const props = defineProps<{
message: {
_id: string
content: string
subagentBlocks: SubagentBlock[]
collapsed: boolean
}
}>()
const emit = defineEmits<{ toggle: [messageId: string] }>()
const agentIcons: Record<string, string> = {
research: '🔬',
indicator: '📊',
strategy: '📈',
}
const headerLabel = computed(() => {
const firstLine = props.message.content.split('\n')[0]
return firstLine.replace(/^⚙\s*/, '') || 'Processing...'
})
const isActive = computed(() => props.message.subagentBlocks.some(b => b.isActive))
</script>
<template>
<div class="tc-bubble">
<div class="tc-header" @click="emit('toggle', message._id)">
<span class="tc-icon"></span>
<span class="tc-label">{{ headerLabel }}</span>
<div v-if="isActive" class="tc-spinner"></div>
<span v-else class="tc-done"></span>
<span class="tc-chevron">{{ message.collapsed ? '▼' : '▲' }}</span>
</div>
<div class="tc-body" :class="{ 'tc-body--collapsed': message.collapsed }">
<div v-for="block in message.subagentBlocks" :key="block.agentName" class="tc-agent-block">
<div class="tc-agent-name">
{{ agentIcons[block.agentName] ?? '🤖' }} {{ block.agentName }}
</div>
<div class="tc-agent-content">{{ block.content }}</div>
</div>
</div>
</div>
</template>
<style scoped>
.tc-bubble {
background: #141414;
color: #dbdbdb;
border-radius: 8px;
border: 1px solid #2e2e2e;
font-size: 13px;
min-width: 200px;
max-width: 100%;
overflow: hidden;
}
.tc-header {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 10px;
cursor: pointer;
user-select: none;
}
.tc-header:hover {
background: rgba(255, 255, 255, 0.04);
}
.tc-icon {
font-size: 13px;
}
.tc-label {
flex: 1;
font-size: 13px;
font-weight: 500;
}
.tc-spinner {
width: 10px;
height: 10px;
border: 2px solid rgba(8, 153, 129, 0.3);
border-top-color: #089981;
border-radius: 50%;
animation: tc-spin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes tc-spin {
to { transform: rotate(360deg); }
}
.tc-done {
color: #089981;
font-size: 12px;
}
.tc-chevron {
font-size: 10px;
opacity: 0.5;
}
.tc-body {
overflow: hidden;
max-height: 300px;
overflow-y: auto;
border-top: 1px solid #2e2e2e;
transition: max-height 0.25s ease;
}
.tc-body--collapsed {
max-height: 0 !important;
border-top-color: transparent;
}
.tc-agent-block {
padding: 6px 10px 8px;
}
.tc-agent-block + .tc-agent-block {
border-top: 1px solid rgba(46, 46, 46, 0.5);
}
.tc-agent-name {
font-size: 11px;
font-weight: 600;
color: #8a8a8a;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tc-agent-content {
font-size: 12px;
font-family: monospace;
color: #b8b8b8;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useIndicatorTypesStore } from '../../stores/indicatorTypes'
import CategoryItemList from '../CategoryItemList.vue'
const store = useIndicatorTypesStore()
const { types } = storeToRefs(store)
const rows = computed(() =>
Object.entries(types.value).map(([id, t]) => ({ id, display_name: t.display_name, description: t.description }))
)
</script>
<template>
<CategoryItemList category="indicator" :rows="rows" />
</template>

View File

@@ -1,108 +1,17 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useResearchTypesStore } from '../../stores/researchTypes'
import CategoryItemList from '../CategoryItemList.vue'
const store = useResearchTypesStore()
const { types } = storeToRefs(store)
const expanded = ref<Set<string>>(new Set())
const rows = computed(() =>
Object.entries(types.value).map(([id, t]) => ({ id, ...t }))
Object.entries(types.value).map(([id, t]) => ({ id, display_name: t.display_name, description: t.description }))
)
function toggle(id: string) {
if (expanded.value.has(id)) {
expanded.value.delete(id)
} else {
expanded.value.add(id)
}
}
</script>
<template>
<div class="research-tab">
<div v-if="rows.length === 0" class="empty">No research items</div>
<div v-for="row in rows" :key="row.id" class="research-row">
<button class="row-header" @click="toggle(row.id)">
<i class="pi" :class="expanded.has(row.id) ? 'pi-chevron-down' : 'pi-chevron-right'" />
<span class="row-name">{{ row.display_name }}</span>
<span class="row-id">{{ row.id }}</span>
</button>
<div v-if="expanded.has(row.id)" class="row-body">
<span v-if="row.description">{{ row.description }}</span>
<span v-else class="no-desc">No description</span>
</div>
</div>
</div>
<CategoryItemList category="research" :rows="rows" />
</template>
<style scoped>
.research-tab {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.empty {
color: #555;
text-align: center;
padding: 16px;
font-size: 12px;
}
.research-row {
border-bottom: 1px solid #1e1e1e;
}
.row-header {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
background: none;
border: none;
padding: 5px 10px;
cursor: pointer;
text-align: left;
color: #dbdbdb;
font-size: 12px;
}
.row-header:hover {
background: #1a1a1a;
}
.row-header .pi {
color: #666;
font-size: 10px;
flex-shrink: 0;
}
.row-name {
flex: 1;
font-weight: 500;
}
.row-id {
color: #555;
font-size: 11px;
font-family: monospace;
}
.row-body {
padding: 6px 26px 8px;
font-size: 12px;
color: #aaa;
line-height: 1.5;
background: #0d0d0d;
white-space: pre-wrap;
}
.no-desc {
color: #444;
font-style: italic;
}
</style>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useStrategyTypesStore } from '../../stores/strategyTypes'
import CategoryItemList from '../CategoryItemList.vue'
const store = useStrategyTypesStore()
const { types } = storeToRefs(store)
const rows = computed(() =>
Object.entries(types.value).map(([id, t]) => ({ id, display_name: t.display_name, description: t.description }))
)
</script>
<template>
<CategoryItemList category="strategy" :rows="rows" />
</template>

View File

@@ -159,6 +159,10 @@ const fetchedRanges = new Map<string, { fromTime: number; toTime: number }>()
// constructor can query the current visible range.
let _tvWidget: any = null
// Set of pandas_ta_names that were registered as named TV studies at widget init.
// Types that arrive after init are NOT in TV's registry, so must use the generic fallback.
const _registeredStudyNames = new Set<string>()
// ---------------------------------------------------------------------------
// Generic study design constants
// ---------------------------------------------------------------------------
@@ -443,7 +447,16 @@ export function getCustomIndicatorsGetter(
const typeKeys = Object.keys(types)
console.log('[CustomIndicators] custom_indicators_getter called, types in store:', typeKeys)
const namedStudies = Object.values(types).map(makeNamedStudy)
_registeredStudyNames.clear()
const namedStudies = Object.values(types).flatMap(type => {
try {
_registeredStudyNames.add(type.pandas_ta_name)
return [makeNamedStudy(type)]
} catch (err) {
console.error('[CustomIndicators] Skipping malformed indicator type:', type?.pandas_ta_name, err)
return []
}
})
const testStudy = makeTestStudy(PineJS)
const studies = [
makeGenericStudy('dxo_customstudy_overlay', true),
@@ -526,11 +539,12 @@ export function useCustomIndicators(tvWidget: any) {
// Resolve the study type name to use when creating a new TV study
// ------------------------------------------------------------------
function resolveStudyTypeName(pandasTaName: string, pane: string): string {
// TV's createStudy() matches by the `description` field in metainfo, not the internal `name`.
// Named studies have description = display_name (e.g. "TrendFlex"), not "dxo_ind_*".
const typeEntry = indicatorTypesStore.types[pandasTaName]
if (typeEntry) return typeEntry.metadata.display_name
// Generic fallbacks have name === description, so either works.
// Only use the named study if it was registered in TV at widget init time.
// Types that arrived after init are not in TV's registry — fall back to generic.
if (_registeredStudyNames.has(pandasTaName)) {
const typeEntry = indicatorTypesStore.types[pandasTaName]
if (typeEntry) return typeEntry.metadata.display_name
}
return pane === 'price' ? 'dxo_customstudy_overlay' : 'dxo_customstudy_pane'
}

View File

@@ -23,6 +23,8 @@ class WebSocketManager {
private maxReconnectAttempts = Infinity // Keep trying indefinitely
private reconnectDelay = 1000 // Start with 1 second
private maxReconnectDelay = 50000 // Max 50 seconds
private consolePatchInstalled = false
private forwardingLog = false // prevent re-entrancy
/**
* Connect to WebSocket with JWT token for authentication
@@ -101,6 +103,7 @@ class WebSocketManager {
this.isAuthenticated.value = true
this.sessionStatus.value = 'ready'
this.statusMessage.value = ''
this.installConsoleForwarding(message.sessionId)
// Flush any queued messages now that we're authenticated
this.flushMessageQueue()
} else if (message.type === 'error') {
@@ -210,6 +213,29 @@ class WebSocketManager {
}
}
private installConsoleForwarding(sessionId: string) {
if (this.consolePatchInstalled) return
this.consolePatchInstalled = true
const levels = ['log', 'info', 'warn', 'error', 'debug'] as const
for (const level of levels) {
const original = (console[level] as (...args: any[]) => void).bind(console)
console[level] = (...args: any[]) => {
original(...args)
if (!this.forwardingLog && this.ws?.readyState === WebSocket.OPEN) {
this.forwardingLog = true
try {
const message = args.map(a =>
typeof a === 'string' ? a : (() => { try { return JSON.stringify(a) } catch { return String(a) } })()
).join(' ')
this.ws!.send(JSON.stringify({ type: 'client_log', level, message }))
} catch { /* ignore send errors */ }
this.forwardingLog = false
}
}
}
}
disconnect() {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)