Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/client/src/features/chat/ChatSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,10 @@ export function ChatSettingsButton() {
Context
</span>
<span class="text-xs" style={{ color: 'var(--c-text)' }}>
{formatTokens(i().totalTokens)} / {i().contextTokens ? formatTokens(i().contextTokens) : '?'}{' '}
{(() => {
const contextTokens = i().contextTokens
return `${formatTokens(i().totalTokens)} / ${contextTokens != null ? formatTokens(contextTokens) : '?'} `
})()}
<span style={{ color: 'var(--c-text-muted)' }}>({usagePct()}%)</span>
</span>
</div>
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/features/chat/ChatView.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, For, Show } from 'solid-js'
import { createEffect, createSignal, For, Show } from 'solid-js'
import type { WorkItem, AgentStatus } from '@sovereign/core'
import type { ChatMessage } from './types.js'
import { MessageBubble } from './MessageBubble.js'
Expand Down
26 changes: 0 additions & 26 deletions packages/client/src/features/chat/InputArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
inputValue,
setInputValue,
agentStatus as storeAgentStatus,
turns,
sendMessage,
abortChat,
compacting,
Expand Down Expand Up @@ -144,30 +143,6 @@ function saveScratchpadEntries(sk: string, entries: ScratchpadEntry[]): void {
}
}

// ── File upload ─────────────────────────────────────────────────

async function uploadFiles(files: FileList | File[]): Promise<{ name: string; path: string; size: number }[]> {
const results: { name: string; path: string; size: number }[] = []
for (const file of Array.from(files)) {
results.push({ name: file.name, path: URL.createObjectURL(file), size: file.size })
}
return results
}

/** Convert a File to base64 string */
async function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
// Strip the data:...;base64, prefix
resolve(result.split(',')[1] || result)
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}

// ── Component ────────────────────────────────────────────────────────

export interface InputAreaProps {
Expand Down Expand Up @@ -492,7 +467,6 @@ export function InputArea(props: InputAreaProps) {
}

const currentAgentStatus = () => props.agentStatus ?? storeAgentStatus()
const statusText = () => getStatusText(currentAgentStatus())
const busy = () => isAgentBusy(currentAgentStatus())

const isBusyOrStreaming = () => busy()
Expand Down
4 changes: 1 addition & 3 deletions packages/client/src/features/chat/MessageBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { turns, retrySend, cancelFailedMessage } from './store.js'
import { sanitizeContent, isCompactionMessage } from './sanitize.js'
import {
WriteIcon,
HeartIcon,
BotIcon,
SystemIcon,
SplitIcon,
Expand All @@ -19,8 +18,7 @@ import {
ListIcon,
ExternalLinkIcon,
CloseIcon,
ChevronDownIcon,
PinIcon
ChevronDownIcon
} from '../../ui/icons.js'

// ── Icons ────────────────────────────────────────────────────────────
Expand Down
25 changes: 16 additions & 9 deletions packages/client/src/features/chat/SubagentView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { createSignal, createEffect, onCleanup, For, Show } from 'solid-js'
import type { ParsedTurn } from '@sovereign/core'
import type { ChatMessage } from './types.js'
import { ChatView } from './ChatView.js'
import { ChevronDownIcon } from '../../ui/icons.js'

export interface SubagentNavEntry {
sessionKey: string
Expand Down Expand Up @@ -48,7 +47,7 @@ export function SubagentView(props: SubagentViewProps) {

createEffect(() => {
// Re-fetch when nav stack changes
const _entry = currentEntry()
currentEntry()
setLoading(true)
setTurns([])
fetchHistory()
Expand All @@ -60,15 +59,21 @@ export function SubagentView(props: SubagentViewProps) {
const last = t[t.length - 1]
const isComplete = last?.role === 'assistant' && last?.content && !last?.workItems?.length
if (isComplete) {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
return
}
fetchHistory()
}, 10000)
})

onCleanup(() => {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
})

const messages = (): ChatMessage[] =>
Expand All @@ -79,9 +84,7 @@ export function SubagentView(props: SubagentViewProps) {

// Build breadcrumb trail
const breadcrumbs = (): Array<{ label: string; depth: number }> => {
const crumbs: Array<{ label: string; depth: number }> = [
{ label: props.parentLabel, depth: -1 }
]
const crumbs: Array<{ label: string; depth: number }> = [{ label: props.parentLabel, depth: -1 }]
for (let i = 0; i < props.navStack.length; i++) {
const entry = props.navStack[i]
const label = entry.label.length > 30 ? entry.label.slice(0, 30) + '…' : entry.label
Expand Down Expand Up @@ -111,7 +114,9 @@ export function SubagentView(props: SubagentViewProps) {
{(crumb, i) => (
<>
<Show when={i() > 0}>
<span class="text-[10px]" style={{ color: 'var(--c-text-muted)' }}>›</span>
<span class="text-[10px]" style={{ color: 'var(--c-text-muted)' }}>
</span>
</Show>
<button
class="shrink-0 truncate rounded px-1.5 py-0.5 text-[11px] transition-colors"
Expand Down Expand Up @@ -140,7 +145,9 @@ export function SubagentView(props: SubagentViewProps) {
{/* Loading state */}
<Show when={loading() && turns().length === 0}>
<div class="flex flex-1 items-center justify-center">
<span class="text-sm" style={{ color: 'var(--c-text-muted)' }}>Loading subagent history…</span>
<span class="text-sm" style={{ color: 'var(--c-text-muted)' }}>
Loading subagent history…
</span>
</div>
</Show>

Expand Down
5 changes: 4 additions & 1 deletion packages/client/src/features/chat/ThreadSettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,10 @@ export default function ThreadSettingsModal(props: { onClose: () => void }) {
Context Usage
</span>
<span class="text-xs" style={{ color: 'var(--c-text)' }}>
{formatTokens(i().totalTokens)} / {i().contextTokens ? formatTokens(i().contextTokens) : '?'}
{(() => {
const contextTokens = i().contextTokens
return `${formatTokens(i().totalTokens)} / ${contextTokens != null ? formatTokens(contextTokens) : '?'}`
})()}
</span>
</div>
<div class="h-2 w-full overflow-hidden rounded-full" style={{ background: 'var(--c-bg)' }}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect } from 'vitest'
import { stripThinkingBlocks } from '../../lib/markdown.js'

// Mock localStorage for Node test environment
Expand Down
11 changes: 2 additions & 9 deletions packages/client/src/features/chat/reliability.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
// §R Chat Reliability Tests — comprehensive tests for all 8 improvements
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createSignal } from 'solid-js'

const store: Record<string, string> = {}
const localStorageMock = {
getItem: (key: string) => store[key] ?? null,
Expand All @@ -17,17 +15,14 @@ Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, wri

import {
turns,
agentStatus,
sendMessage,
initChatStore,
_resetState,
pendingQueue,
connectionLost,
setConnectionLost,
handleAck,
handleNack,
setPendingQueue,
setTurns
handleNack
} from './store.js'
import type { ParsedTurn } from '@sovereign/core'

Expand Down Expand Up @@ -147,7 +142,7 @@ describe('§R Chat Reliability Improvements', () => {
// After all retries exhaust, eventually fails
vi.advanceTimersByTime(120_000) // well past all retries

const failed = pendingQueue().find((m) => m.status === 'failed')
pendingQueue().find((m) => m.status === 'failed')
// Either failed or retried and still pending
expect(pendingQueue().length).toBeGreaterThanOrEqual(1)
})
Expand Down Expand Up @@ -193,8 +188,6 @@ describe('§R Chat Reliability Improvements', () => {
describe('§R.6 Retry backoff', () => {
it('retries with increasing delay after timeout', async () => {
await sendMessage('retry test')
const initialSendCount = ws.send.mock.calls.length

// First timeout → retry
vi.advanceTimersByTime(16_000)
// Should have retried (status changed to pending then back to sending)
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/features/chat/store.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createSignal, createRoot } from 'solid-js'
import { createSignal } from 'solid-js'

const store: Record<string, string> = {}
const localStorageMock = {
Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/features/chat/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,6 @@ let lastSSESeq = 0

// Content-hash dedup window for user-message SSE events
const recentUserMessages = new Map<string, number>() // content -> timestamp
const USER_MSG_DEDUP_WINDOW_MS = 10_000

// §R.4 Send timeout guard — pending ack map
const SEND_TIMEOUT_MS = 15_000
Expand Down Expand Up @@ -336,6 +335,7 @@ export function handleAck(ackId: string): void {
export function handleNack(ackId: string, error?: string): void {
const entry = pendingAcks.get(ackId)
if (!entry) return
void error
clearTimeout(entry.timer)
pendingAcks.delete(ackId)
// Mark the send as failed
Expand Down Expand Up @@ -702,7 +702,7 @@ export function initChatStore(_threadKey: Accessor<string>, wsStore?: WsStore):
let prevThreadKey = _threadKey()
const unsubs: Array<() => void> = []

const trackEffect = createEffect(() => {
createEffect(() => {
const key = _threadKey()
if (key !== prevThreadKey) {
prevThreadKey = key
Expand Down
37 changes: 16 additions & 21 deletions packages/client/src/features/dashboard/ActivityFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
import { createSignal, onMount, onCleanup, For, Show } from 'solid-js'
import { getEventIcon, getEventDescription, formatEventTime, MAX_FEED_EVENTS } from './dashboard-helpers.js'
import type { ActivityEvent, EventType } from './dashboard-helpers.js'
import { getEventIcon, formatEventTime, MAX_FEED_EVENTS } from './dashboard-helpers.js'
import type { EventType } from './dashboard-helpers.js'

/** Whitelist of event type prefixes/patterns worth showing */
const ALLOWED_EVENT_PREFIXES = [
'git.',
'issue.', 'review.',
'scheduler.job.',
'meeting.',
'recording.',
'worktree.',
]
const ALLOWED_EVENT_PREFIXES = ['git.', 'issue.', 'review.', 'scheduler.job.', 'meeting.', 'recording.', 'worktree.']

function isEventAllowed(eventType: string, payload?: any): boolean {
function isEventAllowed(eventType: string): boolean {
// Always filter out architecture spam
if (eventType === 'system.architecture.updated') return false
// Filter all log.entry events — too noisy for dashboard activity feed
Expand All @@ -29,13 +22,13 @@ const EVENT_LABEL_MAP: Record<string, string> = {
'system.architecture.updated': 'Architecture updated',
'ws.connected': 'Client connected',
'ws.disconnected': 'Client disconnected',
'config.changed': 'Config updated',
'config.changed': 'Config updated'
}

const EVENT_PREFIX_MAP: [string, string][] = [
['notification.', 'Notification'],
['scheduler.job.', 'Job executed'],
['git.', 'Git activity'],
['git.', 'Git activity']
]

function humanizeEventName(raw: string): string {
Expand All @@ -51,9 +44,7 @@ function humanizeEventName(raw: string): string {
}

// Default: capitalize and clean up
return cleaned
.replace(/\./g, ' ')
.replace(/^\w/, (c) => c.toUpperCase())
return cleaned.replace(/\./g, ' ').replace(/^\w/, (c) => c.toUpperCase())
}

export interface ActivityFeedEntry {
Expand Down Expand Up @@ -92,7 +83,7 @@ export function ActivityFeed() {
if (res.ok) {
const data = await res.json()
const events: ActivityFeedEntry[] = (data.entries ?? data.events ?? [])
.filter((e: any) => isEventAllowed(e.event?.type ?? e.type ?? '', e.event?.payload ?? e.payload))
.filter((e: any) => isEventAllowed(e.event?.type ?? e.type ?? ''))
.slice(0, MAX_FEED_EVENTS)
.map((e: any) => ({
id: String(e.id ?? e.capturedAt ?? Date.now()),
Expand All @@ -104,15 +95,17 @@ export function ActivityFeed() {
store.addEntry(evt)
}
}
} catch { /* no server events endpoint */ }
} catch {
/* no server events endpoint */
}

// Subscribe to WS for live updates
try {
const { wsStore } = await import('../../ws/index.js').catch(() => ({ wsStore: null }))
if (wsStore) {
wsUnsub = wsStore.on('system.event', (msg: any) => {
const eventType = msg.eventType ?? msg.description ?? ''
if (!isEventAllowed(eventType, msg.payload)) return
if (!isEventAllowed(eventType)) return
store.addEntry({
id: String(msg.id ?? Date.now()),
type: (msg.eventType?.split('.')[0] ?? 'system') as EventType,
Expand All @@ -121,7 +114,9 @@ export function ActivityFeed() {
})
})
}
} catch { /* no ws store */ }
} catch {
/* no ws store */
}
})

onCleanup(() => {
Expand All @@ -141,7 +136,7 @@ export function ActivityFeed() {
</p>
}
>
<div ref={containerRef} class="max-h-64 overflow-y-auto space-y-1">
<div ref={containerRef} class="max-h-64 space-y-1 overflow-y-auto">
<For each={store.entries()}>
{(entry) => (
<div class="flex items-start gap-2 py-1 text-xs" style={{ color: 'var(--c-text-secondary)' }}>
Expand Down
1 change: 0 additions & 1 deletion packages/client/src/features/dashboard/DashboardView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ export function formatJobCount(count: number): string {

// ── §2.1 DashboardView Component ──

import WorkspaceCard from './WorkspaceCard'
import type { OrgSummary } from './WorkspaceCard'
import GlobalChat from './GlobalChat'
import VoiceWidget from './VoiceWidget'
Expand Down
10 changes: 8 additions & 2 deletions packages/client/src/features/dashboard/GlobalChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ interface PreviewMessage {
content: string
}

export function getLastMessages<T>(messages: T[], limit: number = GLOBAL_CHAT_MESSAGE_LIMIT): T[] {
return messages.slice(-limit)
}

export default function GlobalChat() {
const [messages, setMessages] = createSignal<PreviewMessage[]>([])
const [status, setStatus] = createSignal('idle')
Expand All @@ -39,7 +43,9 @@ export default function GlobalChat() {
setMessages(data.messages ?? [])
setStatus(data.agentStatus ?? 'idle')
}
} catch { /* ignore */ }
} catch {
/* ignore */
}
})

return (
Expand All @@ -64,7 +70,7 @@ export default function GlobalChat() {
</span>
</button>
<div class="max-h-48 flex-1 space-y-2 overflow-y-auto p-3">
{messages().map((msg) => (
{getLastMessages(messages()).map((msg) => (
<div class="text-xs" style={{ color: 'var(--c-text)' }}>
<span class="font-medium opacity-80">{formatRole(msg.role)}: </span>
<span class="opacity-70">{truncateMessage(msg.content)}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const makeOrg = (overrides: Partial<OrgSummary> = {}): OrgSummary => ({
branchesAhead: 0,
branchesBehind: 0,
activeThreads: 0,
threadCount: 0,
unreadThreads: 0,
errorThreads: 0,
notificationCount: 0,
Expand Down
Loading