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
12 changes: 10 additions & 2 deletions src-tauri/src/streaming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,19 @@ pub async fn stream_sse(
let mut current_id: Option<String> = None;
let mut current_data: Vec<String> = 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;
}
Expand Down
11 changes: 10 additions & 1 deletion src-tauri/src/websocket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/ResponsePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ function ResponsePanelComponent({
<div className="relative flex justify-center">
<div className="relative">
<Send className="h-14 w-14 text-muted-foreground/20 rotate-[-15deg]" />
<ArrowUpRight className="h-5 w-5 text-primary/30 absolute -top-1 -right-1 animate-pulse-soft" />
<ArrowUpRight className="h-5 w-5 text-primary/30 absolute -top-1 -right-1 animate-pulse-soft-intro" />
</div>
</div>
<div className="space-y-2">
Expand Down
7 changes: 7 additions & 0 deletions src/components/ResponseStreamer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 && (
Comment thread
mohnjiles marked this conversation as resolved.
<div className="mb-2 rounded-md border border-border/40 bg-secondary/30 px-2.5 py-1.5 text-[11px] text-muted-foreground">
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.
</div>
)}
<div className="relative bg-muted rounded-md p-1.5 mb-2">
{streaming.error ? (
<pre className="text-sm text-red-400 break-all overflow-wrap-anywhere">
Expand Down
54 changes: 52 additions & 2 deletions src/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -28,8 +28,16 @@ interface SettingsPanelProps {

export const SettingsPanel = forwardRef<HTMLDivElement, SettingsPanelProps>(
({ 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)
Expand Down Expand Up @@ -333,6 +341,48 @@ export const SettingsPanel = forwardRef<HTMLDivElement, SettingsPanelProps>(
</div>
</div>
</div>

Comment thread
mohnjiles marked this conversation as resolved.
<Separator className="bg-border/30" />

{/* Streaming Settings */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Radio className="h-4 w-4 text-primary/60" />
<div>
<h3 className="text-sm font-semibold text-foreground">Streaming</h3>
<p className="text-xs text-muted-foreground">
How much of a long-running stream to keep on screen
</p>
</div>
</div>
<div className="grid gap-6 glass-card bg-secondary/10 p-5 border-border/30">
<div className="space-y-3.5">
<div className="flex justify-between items-center">
<Label className="text-foreground text-[13px] font-semibold">Stream Buffer Limit</Label>
<span className="text-[11px] font-mono font-bold text-primary/90 bg-primary/15 px-2 py-0.5 rounded-md border border-primary/20">
{streaming.maxBufferKB === 0
? 'Unlimited'
: `${(streaming.maxBufferKB / 1024).toFixed(1)} MB`}
</span>
</div>
<Slider
value={[streaming.maxBufferKB]}
min={0}
max={20480}
step={512}
onValueChange={([value]) =>
updateStreamingSettings({ maxBufferKB: value })
}
className="[&_[role=slider]]:bg-primary [&_[role=slider]]:border-primary/80 [&_[role=slider]]:shadow-glow-sm cursor-col-resize"
/>
<p className="text-[11px] text-muted-foreground/70">
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.
</p>
</div>
</div>
</div>
</div>
</ScrollArea>
</SheetContent>
Expand Down
13 changes: 12 additions & 1 deletion src/hooks/useStreamingResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
}
})
Expand Down
15 changes: 15 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,21 @@
}
}

/* A running animation keeps the WebView2 compositor producing frames for as
Comment thread
mohnjiles marked this conversation as resolved.
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)
═══════════════════════════════════════════════════ */
Expand Down
44 changes: 41 additions & 3 deletions src/store/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<JSONViewerSettings>) => Promise<void>
updateNetworkSettings: (settings: Partial<NetworkSettings>) => Promise<void>
updateStreamingSettings: (settings: Partial<StreamingSettings>) => Promise<void>
}

const SETTINGS_FILE = 'settings.json'
Expand All @@ -36,20 +46,42 @@ export const defaultNetworkSettings: NetworkSettings = {
proxy: '',
}

export const defaultStreamingSettings: StreamingSettings = {
maxBufferKB: 2048,
}

export const useSettingsStore = create<SettingsState>()(
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,
})
}
}),
{
Expand All @@ -59,6 +91,7 @@ export const useSettingsStore = create<SettingsState>()(
const data = await loadFromFile<{
jsonViewer: Partial<JSONViewerSettings>
network?: Partial<NetworkSettings>
streaming?: Partial<StreamingSettings>
}>(SETTINGS_FILE, { jsonViewer: defaultJSONSettings })
return {
state: {
Expand All @@ -69,14 +102,19 @@ export const useSettingsStore = create<SettingsState>()(
network: {
...defaultNetworkSettings,
...(data?.network || {})
},
streaming: {
...defaultStreamingSettings,
...(data?.streaming || {})
}
}
}
},
setItem: async (_, value) => {
await saveToFile(SETTINGS_FILE, {
jsonViewer: value.state.jsonViewer,
network: value.state.network
network: value.state.network,
streaming: value.state.streaming
})
},
removeItem: () => {}
Expand Down
4 changes: 4 additions & 0 deletions src/test/SettingsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ vi.mock('@/store/settings', () => ({
proxy: '',
},
updateNetworkSettings: vi.fn(),
streaming: {
maxBufferKB: 2048,
},
updateStreamingSettings: vi.fn(),
}))
}))

Expand Down
63 changes: 63 additions & 0 deletions src/test/streamBuffer.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
2 changes: 2 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface StreamingResponse {
headers: Record<string, string>
chunkCount: number
currentContent: string
/** Characters dropped off the front of `currentContent` by the buffer cap. */
truncatedChars?: number
isComplete: boolean
error?: string
timing?: {
Expand Down
Loading
Loading