bugfixes; research subproc; higher sandbox limits

This commit is contained in:
2026-04-16 18:11:26 -04:00
parent f80c943dc3
commit 3153e89d4f
54 changed files with 1947 additions and 498 deletions

View File

@@ -9,6 +9,8 @@ import { useShapeStore } from './stores/shapes'
import { useIndicatorStore } from './stores/indicators'
import { useIndicatorTypesStore } from './stores/indicatorTypes'
import { useChannelStore } from './stores/channel'
import { useResearchTypesStore } from './stores/researchTypes'
import { useStrategyTypesStore } from './stores/strategyTypes'
import { useStateSync } from './composables/useStateSync'
import { wsManager } from './composables/useWebSocket'
import { authService } from './composables/useAuth'
@@ -44,9 +46,18 @@ function onHDragMove(e: PointerEvent) {
chartWidth.value = Math.max(CHART_MIN_PX, Math.min(maxWidth, hDragStartWidth + delta))
}
// Clamp chartWidth so chart + chat always fit within the window
function clampChartWidth() {
const maxWidth = window.innerWidth - CHAT_MIN_PX - 4
if (maxWidth >= CHART_MIN_PX) {
chartWidth.value = Math.max(CHART_MIN_PX, Math.min(maxWidth, chartWidth.value))
}
}
// Check screen width for mobile layout
const checkMobile = () => {
isMobile.value = window.innerWidth < 768
if (!isMobile.value) clampChartWidth()
}
const chartStore = useChartStore()
@@ -108,11 +119,15 @@ const initializeApp = async () => {
const indicatorStore = useIndicatorStore()
const indicatorTypesStore = useIndicatorTypesStore()
const channelStore = useChannelStore()
const researchTypesStore = useResearchTypesStore()
const strategyTypesStore = useStrategyTypesStore()
const stateSync = useStateSync({
chartState: chartStore,
shapes: shapeStore,
indicators: indicatorStore,
indicator_types: indicatorTypesStore,
research_types: researchTypesStore,
strategy_types: strategyTypesStore,
channelState: channelStore
})
stateSyncCleanup = stateSync.cleanup
@@ -195,7 +210,7 @@ onBeforeUnmount(() => {
.chat-panel {
flex: 1;
min-width: 0;
min-width: 240px;
height: 100%;
overflow: hidden;
display: flex;

View File

@@ -7,6 +7,7 @@ import TabPanels from 'primevue/tabpanels'
import TabPanel from 'primevue/tabpanel'
import OrdersTab from './tabs/OrdersTab.vue'
import PlaceholderTab from './tabs/PlaceholderTab.vue'
import ResearchTab from './tabs/ResearchTab.vue'
interface TempTab {
id: string
@@ -81,9 +82,10 @@ defineExpose({
<template>
<div class="bottom-tray" :style="trayStyle">
<div v-if="isExpanded" class="tray-resize-handle" @pointerdown="startResize" @pointermove="onResizeMove" />
<Tabs :value="activeTab" class="tray-tabs">
<Tabs :value="isExpanded ? activeTab : null" class="tray-tabs">
<TabList class="tray-tab-list">
<Tab value="orders" @click="onTabClick('orders')">Orders</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
@@ -102,9 +104,10 @@ defineExpose({
</button>
</TabList>
<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="positions" class="tray-panel"><PlaceholderTab label="Positions" /></TabPanel>
<TabPanel value="research" class="tray-panel"><ResearchTab /></TabPanel>
<TabPanel
v-for="tab in tempTabs"
:key="tab.id"

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { ref, onMounted, onBeforeUnmount, watch, type WatchStopHandle } from 'vue'
import Card from 'primevue/card'
import { createTradingViewDatafeed } from '../composables/useTradingViewDatafeed'
import { useTradingViewShapes } from '../composables/useTradingViewShapes'
@@ -11,10 +11,11 @@ import type { IChartingLibraryWidget } from '../types/tradingview'
import { intervalToSeconds } from '../utils'
import { wsManager } from '../composables/useWebSocket'
// Convert seconds to TradingView interval string
// Convert seconds to TradingView interval string.
// TradingView uses plain minute numbers ("60", "240") for intraday,
// and "1D", "2D" etc for daily. Never use "H" suffix — it's not in supported_resolutions.
function secondsToInterval(seconds: number): string {
if (seconds % 86400 === 0) return `${seconds / 86400}D`
if (seconds % 3600 === 0) return `${seconds / 3600}H`
return `${seconds / 60}` // plain number = minutes
}
@@ -27,9 +28,23 @@ let shapeCleanup: (() => void) | null = null // Cleanup function for shape sync
let indicatorCleanup: (() => void) | null = null // Cleanup function for indicator sync
let customIndicatorCleanup: (() => void) | null = null // Cleanup for custom TV studies
let chartInitialized = false // Guard against double-init on reconnect
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
}
chartInitialized = true
initChart()
}
@@ -205,6 +220,10 @@ function setupStoreWatchers() {
}
onBeforeUnmount(() => {
if (symbolWatcher) {
symbolWatcher()
symbolWatcher = null
}
// Cleanup shape synchronization
if (shapeCleanup) {
shapeCleanup()

View File

@@ -2,7 +2,6 @@
import { ref, onMounted, onUnmounted, computed, onBeforeUnmount, watch, nextTick } from 'vue'
import { register } from 'vue-advanced-chat'
import Badge from 'primevue/badge'
import Button from 'primevue/button'
import { wsManager } from '../composables/useWebSocket'
import type { WebSocketMessage } from '../composables/useWebSocket'
import { useChannelStore } from '../stores/channel'
@@ -188,12 +187,13 @@ 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')
// Remove any ephemeral tool-call bubble before starting the real response
removeToolCallBubble()
// Set up streaming state and mark user message as seen
isAgentProcessing.value = true
currentStreamingMessageId = generateMessageId()
@@ -314,6 +314,7 @@ const stopAgent = () => {
// Send message handler
const sendMessage = async (event: any) => {
if (isAgentProcessing.value) { stopAgent(); return }
// Extract data from CustomEvent.detail[0]
const data = event.detail?.[0] || event
@@ -617,7 +618,11 @@ onUnmounted(() => {
<!-- Workspace loading overlay -->
<div v-if="!channelStore.isReady" class="workspace-loading">
<i class="pi pi-spin pi-spinner workspace-loading-spinner" />
<svg class="workspace-loading-spinner" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg">
<circle cx="25" cy="25" r="20" fill="none" stroke="rgba(8,153,129,0.2)" stroke-width="4"/>
<circle cx="25" cy="25" r="20" fill="none" stroke="#089981" stroke-width="4"
stroke-dasharray="80 200" stroke-linecap="round"/>
</svg>
<span class="workspace-loading-message">{{ channelStore.statusMessage || 'Connecting...' }}</span>
</div>
@@ -643,18 +648,18 @@ onUnmounted(() => {
@send-message="sendMessage"
@fetch-messages="fetchMessages"
@open-file="openFile"
/>
<!-- Stop button overlay -->
<div v-if="isAgentProcessing" class="stop-button-container">
<Button
icon="pi pi-stop-circle"
label="Stop"
severity="danger"
@click="stopAgent"
class="stop-button"
/>
</div>
>
<div
v-if="isAgentProcessing"
slot="send-icon"
@click.stop="stopAgent"
style="display:flex;align-items:center;justify-content:center;width:100%;height:100%"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20">
<rect x="4" y="4" width="16" height="16" rx="2" fill="#f23645"/>
</svg>
</div>
</vue-advanced-chat>
</div>
</template>
@@ -682,8 +687,13 @@ onUnmounted(() => {
}
.workspace-loading-spinner {
font-size: 2rem;
color: #089981;
width: 2rem;
height: 2rem;
animation: workspace-spin 0.8s linear infinite;
}
@keyframes workspace-spin {
to { transform: rotate(360deg); }
}
.workspace-loading-message {
@@ -721,24 +731,4 @@ onUnmounted(() => {
color: var(--p-surface-900);
}
.stop-button-container {
position: absolute;
bottom: 80px;
right: 20px;
z-index: 1000;
}
.stop-button {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.8;
}
}
</style>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useResearchTypesStore } from '../../stores/researchTypes'
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 }))
)
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>
</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

@@ -60,8 +60,6 @@ export function useStateSync(stores: Record<string, Store>) {
currentSeqs[msg.store] = msg.seq;
saveStoredSeqs(currentSeqs);
console.log('[StateSync] Snapshot applied, new seq:', msg.seq);
} else {
console.warn('[StateSync] Store not found:', msg.store);
}
} else if (msg.type === 'patch') {
console.log('[StateSync] Processing patch for store:', msg.store, 'seq:', msg.seq);
@@ -89,8 +87,6 @@ export function useStateSync(stores: Record<string, Store>) {
currentSeqs[msg.store] = msg.seq;
saveStoredSeqs(currentSeqs);
console.log('[StateSync] Patch applied successfully, new seq:', msg.seq);
} else {
console.warn('[StateSync] Store not found:', msg.store);
}
}
};

View File

@@ -263,7 +263,10 @@ export class WebSocketDatafeed implements IBasicDataFeed {
throw err
})
.then((response) => {
if (response.history) {
if (response.error) {
console.error('[TradingView Datafeed] getBars server error:', response.error)
onError(response.error)
} else if (response.history) {
console.log('[TradingView Datafeed] Raw bar sample:', response.history.bars?.[0])
console.log('[TradingView Datafeed] Denominators:', denoms)
@@ -309,7 +312,7 @@ export class WebSocketDatafeed implements IBasicDataFeed {
this.sendRequest<any>({
type: 'subscribe_bars',
symbol: symbolInfo.ticker || symbolInfo.name,
resolution: resolution,
period_seconds: intervalToSeconds(resolution),
subscription_id: listenerGuid
})
.then((response) => {
@@ -328,8 +331,10 @@ export class WebSocketDatafeed implements IBasicDataFeed {
}
unsubscribeBars(listenerGuid: string): void {
const sub = this.subscriptions.get(listenerGuid)
this.sendRequest<any>({
type: 'unsubscribe_bars',
period_seconds: sub ? intervalToSeconds(sub.resolution) : 60,
subscription_id: listenerGuid
})
.then(() => {

View File

@@ -30,8 +30,14 @@ class WebSocketManager {
async connect(token: string): Promise<void> {
this.token = token
// Close existing connection if any
// Close existing connection if any — null out handlers first so the async
// onclose event from the old socket cannot reset sessionStatus after the
// new socket has already reached 'ready'.
if (this.ws) {
this.ws.onopen = null
this.ws.onmessage = null
this.ws.onerror = null
this.ws.onclose = null
this.ws.close()
this.ws = null
}

View File

@@ -0,0 +1,14 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface ResearchType {
display_name: string
description?: string
created_at: number
modified_at: number
}
export const useResearchTypesStore = defineStore('research_types', () => {
const types = ref<Record<string, ResearchType>>({})
return { types }
})

View File

@@ -0,0 +1,14 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface StrategyType {
display_name: string
description?: string
created_at: number
modified_at: number
}
export const useStrategyTypesStore = defineStore('strategy_types', () => {
const types = ref<Record<string, StrategyType>>({})
return { types }
})