diff --git a/apps/yaak-client/components/core/Editor/Editor.tsx b/apps/yaak-client/components/core/Editor/Editor.tsx index f5f8f192..739b10c0 100644 --- a/apps/yaak-client/components/core/Editor/Editor.tsx +++ b/apps/yaak-client/components/core/Editor/Editor.tsx @@ -282,6 +282,22 @@ function EditorInner({ [disableTabIndent], ); + // Update read-only + const readOnlyCompartment = useRef(new Compartment()); + useEffect( + function configureReadOnly() { + if (cm.current === null) return; + const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension; + const next = readOnly ? readonlyExtensions : emptyExtension; + // PERF: This is expensive with hundreds of editors on screen, so only do it when necessary + if (current === next) return; + + const effects = readOnlyCompartment.current.reconfigure(next); + cm.current?.view.dispatch({ effects }); + }, + [readOnly], + ); + const onClickFunction = useCallback( async (fn: TemplateFunction, tagValue: string, startPos: number) => { const show = () => { @@ -394,9 +410,9 @@ function EditorInner({ keymapCompartment.current.of( keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default, ), + readOnlyCompartment.current.of(readOnly ? readonlyExtensions : emptyExtension), ...getExtensions({ container, - readOnly, singleLine, hideGutter, stateKey, @@ -553,7 +569,6 @@ function EditorInner({ function getExtensions({ stateKey, container, - readOnly, singleLine, hideGutter, onChange, @@ -562,7 +577,7 @@ function getExtensions({ onFocus, onBlur, onKeyDown, -}: Pick & { +}: Pick & { stateKey: EditorProps["stateKey"]; container: HTMLDivElement | null; onChange: RefObject; @@ -612,7 +627,6 @@ function getExtensions({ keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap), ...(singleLine ? [singleLineExtensions()] : []), ...(!singleLine ? multiLineExtensions({ hideGutter }) : []), - ...(readOnly ? readonlyExtensions : []), // ------------------------ // // Things that must be last // diff --git a/apps/yaak-client/components/core/EventViewer.tsx b/apps/yaak-client/components/core/EventViewer.tsx index 02ad19ed..77ecfc95 100644 --- a/apps/yaak-client/components/core/EventViewer.tsx +++ b/apps/yaak-client/components/core/EventViewer.tsx @@ -9,6 +9,8 @@ import { CopyIconButton } from "../CopyIconButton"; import { AutoScroller } from "./AutoScroller"; import { Button } from "./Button"; import { IconButton } from "./IconButton"; +import type { SelectProps } from "./Select"; +import { Select } from "./Select"; import { Separator } from "./Separator"; interface EventViewerProps { @@ -151,7 +153,7 @@ export function EventViewer({ layout="vertical" storageKey={splitLayoutStorageKey} defaultRatio={defaultRatio} - minHeightPx={10} + minHeightPx={72} firstSlot={({ style }) => (
{header ?? } @@ -202,23 +204,38 @@ export function EventViewer({ ); } -export interface EventDetailAction { - /** Unique key for React */ - key: string; - /** Button label */ - label: string; - /** Optional icon */ - icon?: ReactNode; - /** Click handler */ - onClick: () => void; -} +export type EventDetailAction = + | { + type?: "button"; + /** Unique key for React */ + key: string; + /** Button label */ + label: string; + /** Optional icon */ + icon?: ReactNode; + /** Click handler */ + onClick: () => void; + } + | { + type: "select"; + /** Unique key for React */ + key: string; + /** Select label */ + label: string; + /** Selected value */ + value: string; + /** Select options */ + options: SelectProps["options"]; + /** Change handler */ + onChange: (value: string) => void; + }; interface EventDetailHeaderProps { title: string; prefix?: ReactNode; timestamp?: string; actions?: EventDetailAction[]; - copyText?: string; + copyText?: string | (() => Promise); onClose?: () => void; } @@ -239,40 +256,56 @@ export function EventDetailHeader({

{title}

- {actions?.map((action) => ( - - ))} + {actions?.map((action) => + action.type === "select" ? ( +
+ summarySettings.setEnabled(value === "jsonpath")} + /> +
+ {summarySettings.enabled && ( + <> +
+ + +
+ ) : null + } + stateKey={`sse-summary-result-key-path::${response.requestId}`} + tint={showResultKeyPathWarning ? "notice" : undefined} + onChange={summarySettings.setResultKeyPath} + /> +
+ + + + + )} + + ( +
+ String(index)} + error={events.error ? String(events.error) : null} + splitLayoutStorageKey="sse_events" + defaultRatio={0.4} + renderRow={({ event, index, isActive, onClick }) => ( + } + content={ + + + + {getEventPreview(event, summarySettings.resultKeyPath, filterEventPreviews)} + + + } + /> + )} + renderDetail={({ event, index, onClose }) => ( + + )} + /> +
+ )} + secondSlot={ + showExtractedText + ? ({ style }) => ( + + ) + : null + } + /> + ); } +function SseSummaryFooter({ + error, + fragmentCount, + isLoading, + onRenderMarkdownChange, + renderMarkdown, + resultKeyPath, + style, + summary, +}: { + error: string | null; + fragmentCount: number; + isLoading: boolean; + onRenderMarkdownChange: (renderMarkdown: boolean) => void; + renderMarkdown: boolean; + resultKeyPath: string; + style: CSSProperties; + summary: string; +}) { + const hasSummary = fragmentCount > 0; + const actions = useMemo( + () => [ + { + key: "sse-summary-format", + label: "Extracted text format", + type: "select" as const, + value: renderMarkdown ? "markdown" : "text", + options: [ + { label: "Text", value: "text" }, + { label: "Markdown", value: "markdown" }, + ], + onChange: (value: string) => onRenderMarkdownChange(value === "markdown"), + }, + ], + [onRenderMarkdownChange, renderMarkdown], + ); + + return ( +
+
+ +
+
+ {error != null ? ( + {error} + ) : isLoading ? ( + Loading extracted text... + ) : hasSummary ? ( + renderMarkdown ? ( +
+ {summary} +
+ ) : ( +
+              {summary}
+            
+ ) + ) : ( + + No fragments for {resultKeyPath} + + )} +
+
+ ); +} + +function getEventPreview( + event: ServerSentEvent, + resultKeyPath: string | null, + filterEventPreview: boolean, +): string { + if (filterEventPreview && resultKeyPath != null) { + return (extractSseValueAtPath(event.data, resultKeyPath) ?? event.data).slice(0, 1000); + } + + return event.data.slice(0, 1000); +} + function EventDetail({ + applyJsonPath, event, index, + resultKeyPath, showLarge, showingLarge, setShowLarge, setShowingLarge, onClose, }: { + applyJsonPath: boolean; event: ServerSentEvent; index: number; + resultKeyPath: string | null; showLarge: boolean; showingLarge: boolean; setShowLarge: (v: boolean) => void; setShowingLarge: (v: boolean) => void; onClose: () => void; }) { + const detailText = useMemo( + () => + applyJsonPath && resultKeyPath != null + ? (extractSseValueAtPath(event.data, resultKeyPath) ?? event.data) + : event.data, + [applyJsonPath, event.data, resultKeyPath], + ); const language = useMemo<"text" | "json">(() => { - if (!event?.data) return "text"; - return isJSON(event?.data) ? "json" : "text"; - }, [event?.data]); + if (!detailText) return "text"; + return isJSON(detailText) ? "json" : "text"; + }, [detailText]); return (
@@ -95,7 +364,7 @@ function EventDetail({ prefix={} onClose={onClose} /> - {!showLarge && event.data.length > 1000 * 1000 ? ( + {!showLarge && detailText.length > 1000 * 1000 ? ( Message previews larger than 1MB are hidden
@@ -117,7 +386,7 @@ function EventDetail({
) : ( - + )}
); @@ -142,14 +411,17 @@ function EventLabels({ }) { return ( - - {event.id ?? index} - - {event.eventType && ( - - {event.eventType} - - )} + {event.id ?? index} + {event.eventType && {event.eventType}} ); } + +function EventLabel({ children, isActive }: { children: ReactNode; isActive?: boolean }) { + return ( + + {isActive && } + {children} + + ); +} diff --git a/apps/yaak-client/hooks/useResponseBodyEventSource.ts b/apps/yaak-client/hooks/useResponseBodyEventSource.ts index 8a6cda06..d0c9a2ab 100644 --- a/apps/yaak-client/hooks/useResponseBodyEventSource.ts +++ b/apps/yaak-client/hooks/useResponseBodyEventSource.ts @@ -6,7 +6,12 @@ import { getResponseBodyEventSource } from "../lib/responseBody"; export function useResponseBodyEventSource(response: HttpResponse) { return useQuery({ placeholderData: (prev) => prev, // Keep previous data on refetch - queryKey: ["response-body-event-source", response.id, response.contentLength], + queryKey: [ + "response-body-event-source", + response.id, + response.updatedAt, + response.contentLength, + ], queryFn: () => getResponseBodyEventSource(response), }); } diff --git a/apps/yaak-client/hooks/useResponseBodySseSummary.ts b/apps/yaak-client/hooks/useResponseBodySseSummary.ts new file mode 100644 index 00000000..72a0845e --- /dev/null +++ b/apps/yaak-client/hooks/useResponseBodySseSummary.ts @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; +import type { HttpResponse } from "@yaakapp-internal/models"; +import type { SseSummary } from "@yaakapp-internal/sse"; +import { getResponseBodySseSummary } from "../lib/responseBody"; + +export function useResponseBodySseSummary(response: HttpResponse, resultKeyPath: string | null) { + return useQuery({ + enabled: resultKeyPath != null, + queryKey: [ + "response-body-sse-summary", + response.id, + response.updatedAt, + response.contentLength, + resultKeyPath, + ], + queryFn: () => getResponseBodySseSummary(response, resultKeyPath ?? ""), + }); +} diff --git a/apps/yaak-client/hooks/useSseSummaryResultKeyPath.ts b/apps/yaak-client/hooks/useSseSummaryResultKeyPath.ts new file mode 100644 index 00000000..40d02dcc --- /dev/null +++ b/apps/yaak-client/hooks/useSseSummaryResultKeyPath.ts @@ -0,0 +1,98 @@ +import type { HttpResponse } from "@yaakapp-internal/models"; +import type { GenericCompletionOption } from "@yaakapp-internal/plugins"; +import { useMemo } from "react"; +import type { GenericCompletionConfig } from "../components/core/Editor/genericCompletion"; +import { useKeyValue } from "./useKeyValue"; + +const OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH = "$.choices[0].delta.content"; +const OPENAI_RESPONSES_RESULT_KEY_PATH = "$.delta"; +const ANTHROPIC_RESULT_KEY_PATH = "$.delta.text"; +const GOOGLE_RESULT_KEY_PATH = "$.candidates[0].content.parts[0].text"; + +const sseSummaryResultKeyPathOptions: GenericCompletionOption[] = [ + { + label: OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH, + detail: "ChatGPT (OpenAI)", + type: "constant", + boost: 1, + }, + { + label: OPENAI_RESPONSES_RESULT_KEY_PATH, + detail: "Responses (OpenAI)", + type: "constant", + boost: 1, + }, + { + label: ANTHROPIC_RESULT_KEY_PATH, + detail: "Claude (Anthropic)", + type: "constant", + boost: 1, + }, + { + label: GOOGLE_RESULT_KEY_PATH, + detail: "Gemini (Google)", + type: "constant", + boost: 1, + }, +]; + +export const sseSummaryResultKeyPathAutocomplete: GenericCompletionConfig = { + minMatch: 0, + options: sseSummaryResultKeyPathOptions, +}; + +export function useSseSummaryResultKeyPath({ response }: { response: HttpResponse }) { + const storedResultKeyPath = useKeyValue({ + namespace: "no_sync", + key: ["sse_summary_result_key_path", response.requestId], + fallback: null, + }); + const enabled = useKeyValue({ + namespace: "no_sync", + key: ["sse_summary_result_key_path_enabled", response.requestId], + fallback: null, + }); + const inferredResultKeyPath = useMemo(() => inferSseSummaryResultKeyPath(response), [response.url]); + const resultKeyPath = storedResultKeyPath.value ?? inferredResultKeyPath; + const trimmedResultKeyPath = resultKeyPath?.trim() ?? ""; + const isEnabled = enabled.value ?? inferredResultKeyPath != null; + + return { + enabled: isEnabled, + inferredResultKeyPath, + resultKeyPath: isEnabled && trimmedResultKeyPath.length > 0 ? trimmedResultKeyPath : null, + resultKeyPathInputValue: resultKeyPath ?? "", + setEnabled: enabled.set, + setResultKeyPath: storedResultKeyPath.set, + }; +} + +function inferSseSummaryResultKeyPath(response: HttpResponse): string | null { + let url: URL; + try { + url = new URL(response.url); + } catch { + return null; + } + + const hostname = url.hostname.toLowerCase(); + const pathname = url.pathname.toLowerCase(); + + if (hostname === "api.openai.com" && pathname === "/v1/chat/completions") { + return OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH; + } + if (hostname === "api.openai.com" && pathname === "/v1/responses") { + return OPENAI_RESPONSES_RESULT_KEY_PATH; + } + if (hostname === "api.anthropic.com" && pathname === "/v1/messages") { + return ANTHROPIC_RESULT_KEY_PATH; + } + if ( + hostname === "generativelanguage.googleapis.com" && + pathname.includes(":streamgeneratecontent") + ) { + return GOOGLE_RESULT_KEY_PATH; + } + + return null; +} diff --git a/apps/yaak-client/lib/responseBody.ts b/apps/yaak-client/lib/responseBody.ts index c1d6b8b3..cc521a08 100644 --- a/apps/yaak-client/lib/responseBody.ts +++ b/apps/yaak-client/lib/responseBody.ts @@ -1,7 +1,8 @@ import { readFile } from "@tauri-apps/plugin-fs"; import type { HttpResponse } from "@yaakapp-internal/models"; import type { FilterResponse } from "@yaakapp-internal/plugins"; -import type { ServerSentEvent } from "@yaakapp-internal/sse"; +import type { ServerSentEvent, SseSummary } from "@yaakapp-internal/sse"; +import { candidateJsonPayloadsFromSseText, computeSseSummary } from "@yaakapp-internal/sse"; import { invokeCmd } from "./tauri"; export async function getResponseBodyText({ @@ -27,9 +28,36 @@ export async function getResponseBodyEventSource( response: HttpResponse, ): Promise { if (!response.bodyPath) return []; - return invokeCmd("cmd_get_sse_events", { - filePath: response.bodyPath, - }); + try { + const events = await invokeCmd("cmd_get_sse_events", { + filePath: response.bodyPath, + }); + if (events.length > 0) { + return events; + } + } catch { + // Fall back to raw JSON frame parsing for non-standard SSE-like responses. + } + + const bytes = await readFile(response.bodyPath); + const text = new TextDecoder("utf-8").decode(bytes); + return candidateJsonPayloadsFromSseText(text).map((data, index) => ({ + data, + eventType: "", + id: String(index), + retry: null, + })); +} + +export async function getResponseBodySseSummary( + response: HttpResponse, + resultKeyPath: string, +): Promise { + if (!response.bodyPath) return { fragmentCount: 0, summary: "" }; + + const bytes = await readFile(response.bodyPath); + const text = new TextDecoder("utf-8").decode(bytes); + return computeSseSummary(text, resultKeyPath); } export async function getResponseBodyBytes( diff --git a/crates/yaak-sse/index.ts b/crates/yaak-sse/index.ts index f41d82df..54cf54d0 100644 --- a/crates/yaak-sse/index.ts +++ b/crates/yaak-sse/index.ts @@ -1 +1,2 @@ export * from "./bindings/sse"; +export * from "./summary"; diff --git a/crates/yaak-sse/package.json b/crates/yaak-sse/package.json index 0bce2079..598f16a4 100644 --- a/crates/yaak-sse/package.json +++ b/crates/yaak-sse/package.json @@ -2,5 +2,11 @@ "name": "@yaakapp-internal/sse", "version": "1.0.0", "private": true, - "main": "index.ts" + "dependencies": { + "jsonpath-plus": "^10.3.0" + }, + "main": "index.ts", + "scripts": { + "test": "vitest run" + } } diff --git a/crates/yaak-sse/summary.test.ts b/crates/yaak-sse/summary.test.ts new file mode 100644 index 00000000..4d20603b --- /dev/null +++ b/crates/yaak-sse/summary.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { computeSseSummary, extractSseValueAtPath } from "./summary"; + +describe("extractSseValueAtPath", () => { + it("supports simple paths", () => { + expect( + extractSseValueAtPath( + JSON.stringify({ choices: [{ delta: { content: "hello" } }] }), + "$.choices[0].delta.content", + ), + ).toBe("hello"); + }); + + it("supports full JSONPath expressions", () => { + expect( + extractSseValueAtPath( + JSON.stringify({ + choices: [ + { delta: { role: "assistant" } }, + { delta: { content: "hello" } }, + { delta: { content: " world" } }, + ], + }), + "$.choices[*].delta.content", + ), + ).toBe("hello world"); + }); + + it("returns null when a JSONPath expression has no matches", () => { + expect(extractSseValueAtPath(JSON.stringify({ delta: {} }), "$.delta.text")).toBeNull(); + }); +}); + +describe("computeSseSummary", () => { + it("concatenates JSONPath matches across SSE messages", () => { + expect( + computeSseSummary( + [ + `data: ${JSON.stringify({ choices: [{ delta: { content: "hello" } }] })}`, + "", + `data: ${JSON.stringify({ choices: [{ delta: { content: " world" } }] })}`, + "", + ].join("\n"), + "$.choices[*].delta.content", + ), + ).toEqual({ + fragmentCount: 2, + summary: "hello world", + }); + }); +}); diff --git a/crates/yaak-sse/summary.ts b/crates/yaak-sse/summary.ts new file mode 100644 index 00000000..1649c9b5 --- /dev/null +++ b/crates/yaak-sse/summary.ts @@ -0,0 +1,131 @@ +import { JSONPath } from "jsonpath-plus"; + +export interface SseSummary { + fragmentCount: number; + summary: string; +} + +type JSONPathJson = null | boolean | number | string | object | unknown[]; + +const STANDARD_SSE_FIELD = /^(event|id|retry):/i; + +export function candidateJsonPayloadsFromSseText(text: string): string[] { + const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const blocks = normalized.split(/\n{2,}/); + const candidates: string[] = []; + + for (const block of blocks) { + const lines = block.split("\n"); + const dataLines = lines + .map((line) => { + const match = /^data:(?: ?)(.*)$/.exec(line); + return match?.[1]; + }) + .filter((line): line is string => line != null); + + if (dataLines.length > 0) { + const payload = dataLines.join("\n").trim(); + if (payload) { + candidates.push(payload); + } + continue; + } + + const trimmedBlock = block.trim(); + if (!trimmedBlock) { + continue; + } + + if (isParsableJson(trimmedBlock)) { + candidates.push(trimmedBlock); + continue; + } + + for (const line of lines) { + const trimmedLine = line.trim(); + if ( + !trimmedLine || + trimmedLine.startsWith(":") || + STANDARD_SSE_FIELD.test(trimmedLine) || + !isParsableJson(trimmedLine) + ) { + continue; + } + candidates.push(trimmedLine); + } + } + + return candidates; +} + +export function computeSseSummary(text: string, keyPath: string): SseSummary { + const fragments: string[] = []; + + for (const payload of candidateJsonPayloadsFromSseText(text)) { + const fragment = extractSseValueAtPath(payload, keyPath); + if (fragment != null) { + fragments.push(fragment); + } + } + + return { + fragmentCount: fragments.length, + summary: fragments.join(""), + }; +} + +export function extractSseValueAtPath(payload: string, keyPath: string): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + return null; + } + + const path = keyPath.trim(); + if (!path) { + return null; + } + + let result: unknown; + try { + result = JSONPath({ path, json: parsed as JSONPathJson }); + } catch { + return null; + } + + if (Array.isArray(result)) { + const fragments = result + .map((item) => stringifySummaryValue(item)) + .filter((item): item is string => item != null); + return fragments.length > 0 ? fragments.join("") : null; + } + + return stringifySummaryValue(result); +} + +function stringifySummaryValue(value: unknown): string | null { + if (value == null) { + return null; + } + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return String(value); + } + try { + return JSON.stringify(value); + } catch { + return null; + } +} + +function isParsableJson(value: string): boolean { + try { + JSON.parse(value); + return true; + } catch { + return false; + } +} diff --git a/package-lock.json b/package-lock.json index 38edb6ed..d1be45b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -356,7 +356,10 @@ }, "crates/yaak-sse": { "name": "@yaakapp-internal/sse", - "version": "1.0.0" + "version": "1.0.0", + "dependencies": { + "jsonpath-plus": "^10.3.0" + } }, "crates/yaak-sync": { "name": "@yaakapp-internal/sync", diff --git a/packages/ui/src/components/SplitLayout.tsx b/packages/ui/src/components/SplitLayout.tsx index 64d69ac4..b5fd516d 100644 --- a/packages/ui/src/components/SplitLayout.tsx +++ b/packages/ui/src/components/SplitLayout.tsx @@ -27,7 +27,7 @@ interface Props { resizeHandleClassName?: string; } -const baseProperties = { minWidth: 0 }; +const baseProperties = { minHeight: 0, minWidth: 0 }; const areaL = { ...baseProperties, gridArea: "left" }; const areaR = { ...baseProperties, gridArea: "right" }; const areaD = { ...baseProperties, gridArea: "drag" }; @@ -60,23 +60,25 @@ export function SplitLayout({ const size = useContainerSize(containerRef); const verticalBasedOnSize = size.width !== 0 && size.width < STACK_VERTICAL_WIDTH; const vertical = layout !== "horizontal" && (layout === "vertical" || verticalBasedOnSize); + const renderedWidth = clampSplitRatio(width, minWidthPx, size.width); + const renderedHeight = secondSlot ? clampSplitRatio(height, minHeightPx, size.height) : 0; const styles = useMemo(() => { return { ...style, gridTemplate: vertical ? ` - ' ${areaL.gridArea}' minmax(0,${1 - height}fr) + ' ${areaL.gridArea}' minmax(0,${1 - renderedHeight}fr) ' ${areaD.gridArea}' 0 - ' ${areaR.gridArea}' minmax(${minHeightPx}px,${height}fr) + ' ${areaR.gridArea}' minmax(0,${renderedHeight}fr) / 1fr ` : ` ' ${areaL.gridArea} ${areaD.gridArea} ${areaR.gridArea}' minmax(0,1fr) - / ${1 - width}fr 0 ${width}fr + / ${1 - renderedWidth}fr 0 ${renderedWidth}fr `, }; - }, [style, vertical, height, minHeightPx, width]); + }, [style, vertical, renderedHeight, renderedWidth]); const handleReset = useCallback(() => { if (vertical) setHeight(defaultRatio); @@ -96,22 +98,36 @@ export function SplitLayout({ const containerHeight = $c.clientHeight - Number.parseFloat(paddingTop) - Number.parseFloat(paddingBottom); + if ((vertical && containerHeight <= 0) || (!vertical && containerWidth <= 0)) { + return; + } + const mouseStartX = e.xStart; const mouseStartY = e.yStart; - const startWidth = containerWidth * width; - const startHeight = containerHeight * height; + const startWidth = containerWidth * renderedWidth; + const startHeight = containerHeight * renderedHeight; if (vertical) { - const maxHeightPx = containerHeight - minHeightPx; - const newHeightPx = clamp(startHeight - (e.y - mouseStartY), minHeightPx, maxHeightPx); + const minHeight = Math.min(minHeightPx, containerHeight); + const maxHeightPx = Math.max(minHeight, containerHeight - minHeightPx); + const newHeightPx = clamp(startHeight - (e.y - mouseStartY), minHeight, maxHeightPx); setHeight(newHeightPx / containerHeight); } else { - const maxWidthPx = containerWidth - minWidthPx; - const newWidthPx = clamp(startWidth - (e.x - mouseStartX), minWidthPx, maxWidthPx); + const minWidth = Math.min(minWidthPx, containerWidth); + const maxWidthPx = Math.max(minWidth, containerWidth - minWidthPx); + const newWidthPx = clamp(startWidth - (e.x - mouseStartX), minWidth, maxWidthPx); setWidth(newWidthPx / containerWidth); } }, - [width, height, vertical, minHeightPx, setHeight, minWidthPx, setWidth], + [ + renderedWidth, + renderedHeight, + vertical, + minHeightPx, + setHeight, + minWidthPx, + setWidth, + ], ); return ( @@ -140,3 +156,13 @@ export function SplitLayout({ ); } + +function clampSplitRatio(ratio: number, minPx: number, containerPx: number): number { + if (containerPx <= 0 || minPx <= 0) { + return ratio; + } + + const minRatio = Math.min(1, minPx / containerPx); + const maxRatio = minRatio >= 0.5 ? minRatio : 1 - minRatio; + return clamp(ratio, minRatio, maxRatio); +}