diff --git a/src-tauri/src/streaming.rs b/src-tauri/src/streaming.rs index 1ce6657..d7f4d9a 100644 --- a/src-tauri/src/streaming.rs +++ b/src-tauri/src/streaming.rs @@ -122,11 +122,19 @@ pub async fn stream_sse( let mut current_id: Option = None; let mut current_data: Vec = Vec::new(); let mut cancelled = false; + // Cleared if the cancel sender goes away. `changed()` then resolves + // instantly and forever, so leaving the arm enabled would spin this task at + // 100% of a core for as long as the response body stays open. + let mut cancellable = true; loop { tokio::select! { - _ = cancel_rx.changed() => { - if *cancel_rx.borrow() { + result = cancel_rx.changed(), if cancellable => { + if result.is_err() { + // No cancel can ever arrive now. Keep draining the body so + // the stream still finishes normally, just stop polling. + cancellable = false; + } else if *cancel_rx.borrow() { cancelled = true; break; } diff --git a/src-tauri/src/websocket.rs b/src-tauri/src/websocket.rs index 807bee2..fb39e19 100644 --- a/src-tauri/src/websocket.rs +++ b/src-tauri/src/websocket.rs @@ -116,7 +116,16 @@ pub async fn ws_connect( // Main event loop loop { tokio::select! { - _ = cmd_rx.changed() => { + result = cmd_rx.changed() => { + // The sender lives in `active_ws.connections` under this + // connection id, so an error here means the entry was replaced + // and nothing can ever send or close this socket again. Bail + // out rather than re-polling an arm that is instantly ready, + // which would spin this task at 100% of a core. + if result.is_err() { + break; + } + let cmd = cmd_rx.borrow().clone(); match cmd { WsCommand::Send(data) => { diff --git a/src/components/ResponsePanel.tsx b/src/components/ResponsePanel.tsx index 28038e8..6f45f89 100644 --- a/src/components/ResponsePanel.tsx +++ b/src/components/ResponsePanel.tsx @@ -166,7 +166,7 @@ function ResponsePanelComponent({
- +
diff --git a/src/components/ResponseStreamer.tsx b/src/components/ResponseStreamer.tsx index b031d9a..2f99c39 100644 --- a/src/components/ResponseStreamer.tsx +++ b/src/components/ResponseStreamer.tsx @@ -120,6 +120,13 @@ export function ResponseStreamer({ className="h-full pr-3 [&_[data-radix-scroll-area-thumb]]:bg-accent [&_[data-radix-scroll-area-thumb]]:hover:bg-accent/80" ref={scrollAreaRef} > + {(streaming.truncatedChars ?? 0) > 0 && ( +
+ Showing the most recent output —{' '} + {Math.round((streaming.truncatedChars ?? 0) / 1024).toLocaleString()} KB + trimmed from the start. Raise the stream buffer limit in Settings to keep more. +
+ )}
{streaming.error ? (
diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx
index be9b56c..4200d95 100644
--- a/src/components/SettingsPanel.tsx
+++ b/src/components/SettingsPanel.tsx
@@ -6,7 +6,7 @@ import {
   SheetDescription,
 } from "@/components/ui/sheet"
 import { Button } from "@/components/ui/button"
-import { Settings, RotateCw, Palette, Sliders, RefreshCw, Globe, ShieldCheck } from "lucide-react"
+import { Settings, RotateCw, Palette, Sliders, RefreshCw, Globe, ShieldCheck, Radio } from "lucide-react"
 import { Label } from "@/components/ui/label"
 import { Slider } from "@/components/ui/slider"
 import { Input } from "@/components/ui/input"
@@ -28,8 +28,16 @@ interface SettingsPanelProps {
 
 export const SettingsPanel = forwardRef(
   ({ open, onOpenChange }, _ref) => {
-    const { jsonViewer, updateJSONViewerSettings, network: networkRaw, updateNetworkSettings } = useSettingsStore()
+    const {
+      jsonViewer,
+      updateJSONViewerSettings,
+      network: networkRaw,
+      updateNetworkSettings,
+      streaming: streamingRaw,
+      updateStreamingSettings,
+    } = useSettingsStore()
     const network = networkRaw ?? { timeout: 30, connectTimeout: 10, sslVerification: true, proxy: '' }
+    const streaming = streamingRaw ?? { maxBufferKB: 2048 }
     const { color: themeColor, setColor: setThemeColor } = useThemeStore()
     const themeClass = useThemeClass()
     const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
@@ -333,6 +341,48 @@ export const SettingsPanel = forwardRef(
                   
+ + + + {/* Streaming Settings */} +
+
+ +
+

Streaming

+

+ How much of a long-running stream to keep on screen +

+
+
+
+
+
+ + + {streaming.maxBufferKB === 0 + ? 'Unlimited' + : `${(streaming.maxBufferKB / 1024).toFixed(1)} MB`} + +
+ + updateStreamingSettings({ maxBufferKB: value }) + } + className="[&_[role=slider]]:bg-primary [&_[role=slider]]:border-primary/80 [&_[role=slider]]:shadow-glow-sm cursor-col-resize" + /> +

+ Keep only the most recent output from a stream, trimming the start once + this limit is passed. Set to 0 to keep everything — a stream left open for + hours will then grow without bound and steadily slow the response view. +

+
+
+
diff --git a/src/hooks/useStreamingResponse.ts b/src/hooks/useStreamingResponse.ts index b8b9aaa..cecb5f1 100644 --- a/src/hooks/useStreamingResponse.ts +++ b/src/hooks/useStreamingResponse.ts @@ -2,7 +2,9 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { invoke } from '@tauri-apps/api/core' import { listen } from '@tauri-apps/api/event' import { StreamChunk, StreamingResponse } from '@/types' +import { useSettingsStore } from '@/store/settings' import { + appendStreamContent, StreamDonePayload, StreamHeaderPayload, StreamRequestOptions, @@ -70,10 +72,19 @@ export function useStreamingResponse() { setStreaming((prev) => { if (!prev) return null + // Read straight from the store rather than subscribing: this fires + // once per chunk and must not re-render on unrelated settings edits. + const { maxBufferKB } = useSettingsStore.getState().streaming + const { content, droppedChars } = appendStreamContent( + prev.currentContent, + chunk.data, + maxBufferKB + ) return { ...prev, chunkCount: prev.chunkCount + 1, - currentContent: prev.currentContent + chunk.data, + currentContent: content, + truncatedChars: (prev.truncatedChars ?? 0) + droppedChars, timing: createStreamingTiming(startTime.current), } }) diff --git a/src/index.css b/src/index.css index a11dc30..785c3a8 100644 --- a/src/index.css +++ b/src/index.css @@ -479,6 +479,21 @@ } } +/* A running animation keeps the WebView2 compositor producing frames for as + long as it lasts, so honour the OS "show animations" setting: with it off, + nothing here should tick in the background. */ +@media (prefers-reduced-motion: reduce) { + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + /* ═══════════════════════════════════════════════════ SCROLLBAR STYLING (for supporting browsers) ═══════════════════════════════════════════════════ */ diff --git a/src/store/settings.ts b/src/store/settings.ts index 4845c14..752cd9d 100644 --- a/src/store/settings.ts +++ b/src/store/settings.ts @@ -15,11 +15,21 @@ export interface NetworkSettings { proxy: string // proxy URL or empty string } +export interface StreamingSettings { + /** + * Trailing characters of a stream body to keep, in KB. 0 means unlimited. + * Caps the per-chunk render cost of long-lived streams. + */ + maxBufferKB: number +} + interface SettingsState { jsonViewer: JSONViewerSettings network: NetworkSettings + streaming: StreamingSettings updateJSONViewerSettings: (settings: Partial) => Promise updateNetworkSettings: (settings: Partial) => Promise + updateStreamingSettings: (settings: Partial) => Promise } const SETTINGS_FILE = 'settings.json' @@ -36,20 +46,42 @@ export const defaultNetworkSettings: NetworkSettings = { proxy: '', } +export const defaultStreamingSettings: StreamingSettings = { + maxBufferKB: 2048, +} + export const useSettingsStore = create()( persist( (set, get) => ({ jsonViewer: defaultJSONSettings, network: defaultNetworkSettings, + streaming: defaultStreamingSettings, updateJSONViewerSettings: async (settings) => { const nextSettings = { ...get().jsonViewer, ...settings } set({ jsonViewer: nextSettings }) - await saveToFile(SETTINGS_FILE, { jsonViewer: nextSettings, network: get().network }) + await saveToFile(SETTINGS_FILE, { + jsonViewer: nextSettings, + network: get().network, + streaming: get().streaming, + }) }, updateNetworkSettings: async (settings) => { const nextNetwork = { ...get().network, ...settings } set({ network: nextNetwork }) - await saveToFile(SETTINGS_FILE, { jsonViewer: get().jsonViewer, network: nextNetwork }) + await saveToFile(SETTINGS_FILE, { + jsonViewer: get().jsonViewer, + network: nextNetwork, + streaming: get().streaming, + }) + }, + updateStreamingSettings: async (settings) => { + const nextStreaming = { ...get().streaming, ...settings } + set({ streaming: nextStreaming }) + await saveToFile(SETTINGS_FILE, { + jsonViewer: get().jsonViewer, + network: get().network, + streaming: nextStreaming, + }) } }), { @@ -59,6 +91,7 @@ export const useSettingsStore = create()( const data = await loadFromFile<{ jsonViewer: Partial network?: Partial + streaming?: Partial }>(SETTINGS_FILE, { jsonViewer: defaultJSONSettings }) return { state: { @@ -69,6 +102,10 @@ export const useSettingsStore = create()( network: { ...defaultNetworkSettings, ...(data?.network || {}) + }, + streaming: { + ...defaultStreamingSettings, + ...(data?.streaming || {}) } } } @@ -76,7 +113,8 @@ export const useSettingsStore = create()( setItem: async (_, value) => { await saveToFile(SETTINGS_FILE, { jsonViewer: value.state.jsonViewer, - network: value.state.network + network: value.state.network, + streaming: value.state.streaming }) }, removeItem: () => {} diff --git a/src/test/SettingsPanel.test.tsx b/src/test/SettingsPanel.test.tsx index 3e288e7..1cdd6f5 100644 --- a/src/test/SettingsPanel.test.tsx +++ b/src/test/SettingsPanel.test.tsx @@ -20,6 +20,10 @@ vi.mock('@/store/settings', () => ({ proxy: '', }, updateNetworkSettings: vi.fn(), + streaming: { + maxBufferKB: 2048, + }, + updateStreamingSettings: vi.fn(), })) })) diff --git a/src/test/streamBuffer.test.ts b/src/test/streamBuffer.test.ts new file mode 100644 index 0000000..9d4b1a6 --- /dev/null +++ b/src/test/streamBuffer.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest' +import { appendStreamContent } from '@/utils/streaming' + +const KB = 1024 + +describe('appendStreamContent', () => { + it('appends without trimming while under the limit', () => { + const result = appendStreamContent('abc', 'def', 1) + expect(result.content).toBe('abcdef') + expect(result.droppedChars).toBe(0) + }) + + it('treats a limit of 0 as unlimited', () => { + const previous = 'x'.repeat(5 * KB) + const result = appendStreamContent(previous, 'y'.repeat(5 * KB), 0) + expect(result.content).toHaveLength(10 * KB) + expect(result.droppedChars).toBe(0) + }) + + it('treats a negative limit as unlimited', () => { + const result = appendStreamContent('x'.repeat(4 * KB), 'y', -1) + expect(result.droppedChars).toBe(0) + }) + + it('keeps the buffer bounded at the limit', () => { + const result = appendStreamContent('a'.repeat(3 * KB), 'b'.repeat(1 * KB), 2) + expect(result.content.length).toBeLessThanOrEqual(2 * KB) + expect(result.droppedChars).toBe(2 * KB) + }) + + it('retains the most recent output, not the oldest', () => { + const result = appendStreamContent('old'.repeat(KB), 'NEWEST', 1) + expect(result.content.endsWith('NEWEST')).toBe(true) + }) + + it('cuts at a line break so the first visible line is whole', () => { + // 2KB of numbered lines, capped to 1KB: the retained text must start + // cleanly at a line boundary rather than mid-line. + const lines = Array.from({ length: 300 }, (_, i) => `line-${i}`).join('\n') + const result = appendStreamContent(lines, '\nlast', 1) + expect(result.content.startsWith('line-')).toBe(true) + }) + + it('does not hunt for a line break beyond the alignment window', () => { + // No newline anywhere in range, so the cut lands exactly on the limit. + const result = appendStreamContent('z'.repeat(8 * KB), '', 2) + expect(result.content).toHaveLength(2 * KB) + expect(result.droppedChars).toBe(6 * KB) + }) + + it('stays bounded across many successive appends', () => { + let content = '' + let dropped = 0 + for (let i = 0; i < 500; i++) { + const next = appendStreamContent(content, `chunk ${i} payload\n`, 4) + content = next.content + dropped += next.droppedChars + } + expect(content.length).toBeLessThanOrEqual(4 * KB) + expect(dropped).toBeGreaterThan(0) + expect(content.endsWith('chunk 499 payload\n')).toBe(true) + }) +}) diff --git a/src/types/index.ts b/src/types/index.ts index 09c737c..5e01d9f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -25,6 +25,8 @@ export interface StreamingResponse { headers: Record chunkCount: number currentContent: string + /** Characters dropped off the front of `currentContent` by the buffer cap. */ + truncatedChars?: number isComplete: boolean error?: string timing?: { diff --git a/src/utils/streaming.ts b/src/utils/streaming.ts index a225dfb..40470db 100644 --- a/src/utils/streaming.ts +++ b/src/utils/streaming.ts @@ -58,6 +58,45 @@ export function shouldIgnoreStreamChunk(chunk: Pick