fix: resolve black terminal on background tab init and reduce resize storm (#334)
This commit is contained in:
@@ -29,6 +29,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
if: ${{ !contains(runner.name, 'homelab') }}
|
||||
|
||||
@@ -41,6 +41,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -85,7 +87,7 @@ jobs:
|
||||
echo "${CHANGED_FILES}"
|
||||
echo "${CHANGED_FILES}" \
|
||||
| sed 's#^apps/##' \
|
||||
| xargs pnpm --dir apps exec prettier --check --ignore-unknown
|
||||
| xargs pnpm exec prettier --check --ignore-unknown
|
||||
|
||||
- name: Run lint
|
||||
run: pnpm --filter @kandev/web lint
|
||||
|
||||
@@ -623,7 +623,7 @@
|
||||
color: var(--accent);
|
||||
padding: 0.1rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.90em;
|
||||
font-size: 0.9em;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
|
||||
@@ -139,16 +139,12 @@ export function Graph2StepNode({
|
||||
className={cn(
|
||||
NODE_CLASS,
|
||||
"cursor-pointer transition-colors bg-background hover:bg-accent/30",
|
||||
running
|
||||
? "border-1 border-accent/50 node-border-running"
|
||||
: "border-1 border-accent/50",
|
||||
running ? "border-1 border-accent/50 node-border-running" : "border-1 border-accent/50",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 w-full">
|
||||
<div className="shrink-0">{getTaskStateIcon(task.state, "h-3 w-3")}</div>
|
||||
<span className="text-[11px] font-medium text-foreground truncate">
|
||||
{step.title}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-foreground truncate">{step.title}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -194,7 +194,7 @@ export function ChatInputBody({
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative flex flex-col border rounded ",
|
||||
isPanelFocused ? "bg-background border-border" : "bg-background/40 border-border",
|
||||
"bg-background border-border",
|
||||
isAgentBusy && !planModeEnabled && "chat-input-running",
|
||||
isAgentBusy && planModeEnabled && "chat-input-running-plan",
|
||||
planModeEnabled && !isAgentBusy && "border-slate-400/50",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import React, { useEffect, useRef, useCallback, useMemo, useState } from "react";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { AttachAddon } from "@xterm/addon-attach";
|
||||
@@ -39,6 +39,7 @@ type TerminalInitOptions = {
|
||||
resizeTimeoutRef: React.MutableRefObject<ReturnType<typeof setTimeout> | null>;
|
||||
webglAddonRef: React.MutableRefObject<WebglAddon | null>;
|
||||
fitAndResize: (force?: boolean) => void;
|
||||
onReady: () => void;
|
||||
};
|
||||
|
||||
function initTerminalInstance(
|
||||
@@ -135,6 +136,7 @@ function useTerminalInit({
|
||||
resizeTimeoutRef,
|
||||
webglAddonRef,
|
||||
fitAndResize,
|
||||
onReady,
|
||||
}: TerminalInitOptions) {
|
||||
const refs = {
|
||||
xtermRef,
|
||||
@@ -155,32 +157,39 @@ function useTerminalInit({
|
||||
log("Already initialized");
|
||||
return;
|
||||
}
|
||||
const initWhenReady = () => {
|
||||
|
||||
const tryInit = () => {
|
||||
if (isInitializedRef.current) return true;
|
||||
const rect = container.getBoundingClientRect();
|
||||
log("Init check: dimensions", rect.width, "x", rect.height);
|
||||
if (rect.width >= MIN_WIDTH && rect.height >= MIN_HEIGHT) {
|
||||
initTerminalInstance(container, refs, fitAndResize);
|
||||
onReady();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (!initWhenReady()) {
|
||||
let retryCount = 0;
|
||||
const maxRetries = 30;
|
||||
const retry = () => {
|
||||
if (isInitializedRef.current) return;
|
||||
retryCount++;
|
||||
if (initWhenReady() || retryCount >= maxRetries) {
|
||||
if (retryCount >= maxRetries) {
|
||||
log("Max retries, forcing init");
|
||||
initTerminalInstance(container, refs, fitAndResize);
|
||||
}
|
||||
return;
|
||||
|
||||
// If the container already has dimensions, init immediately.
|
||||
if (tryInit()) {
|
||||
// Already initialized — cleanup handled by initTerminalInstance's return
|
||||
} else {
|
||||
// Container is 0×0 (e.g. terminal is in a background dockview tab).
|
||||
// Use a ResizeObserver to wait until the container becomes visible
|
||||
// instead of polling with rAF and force-initializing at 0×0
|
||||
// (which causes black backgrounds since CSS vars can't be read).
|
||||
log("Container not visible, waiting via ResizeObserver");
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (tryInit()) {
|
||||
observer.disconnect();
|
||||
}
|
||||
requestAnimationFrame(retry);
|
||||
});
|
||||
observer.observe(container);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
requestAnimationFrame(retry);
|
||||
}
|
||||
|
||||
const resizeTimeout = resizeTimeoutRef;
|
||||
const webgl = webglAddonRef;
|
||||
const xterm = xtermRef;
|
||||
@@ -208,6 +217,7 @@ type WebSocketConnectionOptions = {
|
||||
taskId: string | null;
|
||||
sessionId: string | null | undefined;
|
||||
canConnect: boolean;
|
||||
isTerminalReady: boolean;
|
||||
fitAndResize: (force?: boolean) => void;
|
||||
wsBaseUrl: string;
|
||||
mode: "agent" | "shell";
|
||||
@@ -245,7 +255,7 @@ type ConnectWebSocketOptions = {
|
||||
wsRef: React.MutableRefObject<WebSocket | null>;
|
||||
attachAddonRef: React.MutableRefObject<AttachAddon | null>;
|
||||
isMountedCheck: () => boolean;
|
||||
onInterval: (id: ReturnType<typeof setInterval>) => void;
|
||||
onTimeout: (id: ReturnType<typeof setTimeout>) => void;
|
||||
};
|
||||
|
||||
function connectWebSocket({
|
||||
@@ -259,7 +269,7 @@ function connectWebSocket({
|
||||
wsRef,
|
||||
attachAddonRef,
|
||||
isMountedCheck,
|
||||
onInterval,
|
||||
onTimeout,
|
||||
}: ConnectWebSocketOptions) {
|
||||
if (attachAddonRef.current) {
|
||||
attachAddonRef.current.dispose();
|
||||
@@ -283,23 +293,24 @@ function connectWebSocket({
|
||||
const attachAddon = new AttachAddon(ws, { bidirectional: true });
|
||||
terminal.loadAddon(attachAddon);
|
||||
attachAddonRef.current = attachAddon;
|
||||
const triggerRefresh = () => {
|
||||
if (isMountedCheck() && ws.readyState === WebSocket.OPEN) fitAndResize(true);
|
||||
};
|
||||
requestAnimationFrame(triggerRefresh);
|
||||
setTimeout(triggerRefresh, 100);
|
||||
setTimeout(triggerRefresh, 300);
|
||||
setTimeout(triggerRefresh, 500);
|
||||
let refreshCount = 0;
|
||||
const intervalId = setInterval(() => {
|
||||
refreshCount++;
|
||||
if (refreshCount > 5 || !isMountedCheck()) {
|
||||
clearInterval(intervalId);
|
||||
return;
|
||||
// Send initial resize (forced) so the backend knows our terminal dimensions,
|
||||
// then one deferred resize to catch layout settling + force a full redraw.
|
||||
// The WebGL renderer can become stale when the container transitions through
|
||||
// 0×0 (portal system detach/reattach), so we must explicitly refresh.
|
||||
requestAnimationFrame(() => {
|
||||
if (isMountedCheck() && ws.readyState === WebSocket.OPEN) {
|
||||
fitAndResize(true);
|
||||
}
|
||||
triggerRefresh();
|
||||
}, 1000);
|
||||
onInterval(intervalId);
|
||||
});
|
||||
const settleTimeout = setTimeout(() => {
|
||||
if (!isMountedCheck()) return;
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
fitAndResize(true);
|
||||
}
|
||||
// Force full redraw — WebGL canvas may be stale after portal 0×0 transition
|
||||
terminal.refresh(0, terminal.rows - 1);
|
||||
}, 500);
|
||||
onTimeout(settleTimeout);
|
||||
};
|
||||
ws.onclose = (event) => {
|
||||
log("WebSocket closed:", event.code, event.reason);
|
||||
@@ -317,6 +328,7 @@ function useWebSocketConnection({
|
||||
taskId,
|
||||
sessionId,
|
||||
canConnect,
|
||||
isTerminalReady,
|
||||
fitAndResize,
|
||||
wsBaseUrl,
|
||||
mode,
|
||||
@@ -334,20 +346,21 @@ function useWebSocketConnection({
|
||||
mode,
|
||||
terminalId,
|
||||
canConnect,
|
||||
isTerminalReady,
|
||||
hasTerminal: !!xtermRef.current,
|
||||
});
|
||||
if (!taskId || !sessionId || !canConnect) {
|
||||
log("WebSocket effect: early return", { taskId, sessionId, canConnect });
|
||||
if (!taskId || !sessionId || !canConnect || !isTerminalReady) {
|
||||
log("WebSocket effect: early return", { taskId, sessionId, canConnect, isTerminalReady });
|
||||
return;
|
||||
}
|
||||
if (!xtermRef.current || !fitAddonRef.current) {
|
||||
log("Terminal not ready for WebSocket");
|
||||
log("Terminal not ready for WebSocket (refs missing despite isTerminalReady)");
|
||||
return;
|
||||
}
|
||||
const terminal = xtermRef.current;
|
||||
let isMounted = true;
|
||||
let connectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let settleTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
connectTimeout = setTimeout(() => {
|
||||
if (!isMounted) return;
|
||||
connectWebSocket({
|
||||
@@ -361,8 +374,8 @@ function useWebSocketConnection({
|
||||
wsRef,
|
||||
attachAddonRef,
|
||||
isMountedCheck: () => isMounted,
|
||||
onInterval: (id) => {
|
||||
refreshInterval = id;
|
||||
onTimeout: (id) => {
|
||||
settleTimeout = id;
|
||||
},
|
||||
});
|
||||
}, 150);
|
||||
@@ -370,7 +383,7 @@ function useWebSocketConnection({
|
||||
log("WebSocket cleanup");
|
||||
isMounted = false;
|
||||
if (connectTimeout) clearTimeout(connectTimeout);
|
||||
if (refreshInterval) clearInterval(refreshInterval);
|
||||
if (settleTimeout) clearTimeout(settleTimeout);
|
||||
if (attachAddonRef.current) {
|
||||
attachAddonRef.current.dispose();
|
||||
attachAddonRef.current = null;
|
||||
@@ -384,6 +397,7 @@ function useWebSocketConnection({
|
||||
taskId,
|
||||
sessionId,
|
||||
canConnect,
|
||||
isTerminalReady,
|
||||
fitAndResize,
|
||||
wsBaseUrl,
|
||||
mode,
|
||||
@@ -463,10 +477,17 @@ function useFitAndResize({
|
||||
}
|
||||
const { cols, rows } = terminal;
|
||||
const last = lastDimensionsRef.current;
|
||||
if (force || cols !== last.cols || rows !== last.rows) {
|
||||
const changed = cols !== last.cols || rows !== last.rows;
|
||||
const wasZero = last.cols === 0 && last.rows === 0;
|
||||
if (force || changed) {
|
||||
lastDimensionsRef.current = { cols, rows };
|
||||
sendResize(cols, rows);
|
||||
}
|
||||
// Force full redraw when transitioning from uninitialized/zero dimensions —
|
||||
// the WebGL canvas may be stale after the container was at 0×0 (portal moves).
|
||||
if (wasZero || changed) {
|
||||
terminal.refresh(0, terminal.rows - 1);
|
||||
}
|
||||
},
|
||||
[xtermRef, fitAddonRef, terminalRef, lastDimensionsRef, sendResize],
|
||||
);
|
||||
@@ -516,6 +537,12 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [isTerminalReady, setIsTerminalReady] = useState(false);
|
||||
const onTerminalReady = useCallback(() => {
|
||||
log("Terminal ready — will trigger WebSocket connection");
|
||||
setIsTerminalReady(true);
|
||||
}, []);
|
||||
|
||||
const sendResize = useSendResize(wsRef);
|
||||
const fitAndResize = useFitAndResize({
|
||||
xtermRef,
|
||||
@@ -534,11 +561,13 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) {
|
||||
resizeTimeoutRef,
|
||||
webglAddonRef,
|
||||
fitAndResize,
|
||||
onReady: onTerminalReady,
|
||||
});
|
||||
useWebSocketConnection({
|
||||
taskId,
|
||||
sessionId,
|
||||
canConnect,
|
||||
isTerminalReady,
|
||||
fitAndResize,
|
||||
wsBaseUrl,
|
||||
mode,
|
||||
@@ -553,7 +582,7 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) {
|
||||
return (
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="h-full w-full overflow-hidden p-1"
|
||||
className="h-full w-full overflow-hidden p-2 bg-background"
|
||||
style={{ minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -250,6 +250,7 @@ export function ShellTerminal({
|
||||
const isReadOnlyMode = processOutput !== undefined;
|
||||
const storeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const sessionId = propSessionId ?? storeSessionId;
|
||||
|
||||
const { session, isActive, isFailed, errorMessage } = useSession(
|
||||
isReadOnlyMode ? null : sessionId,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useCallback, useEffect, useState, useRef } from "react";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import { launchSession } from "@/lib/services/session-launch-service";
|
||||
import { buildResumeRequest, buildRestoreWorkspaceRequest } from "@/lib/services/session-launch-helpers";
|
||||
import {
|
||||
buildResumeRequest,
|
||||
buildRestoreWorkspaceRequest,
|
||||
} from "@/lib/services/session-launch-helpers";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { TaskSessionState } from "@/lib/types/http";
|
||||
|
||||
@@ -91,7 +94,10 @@ async function resumeViaLaunch(
|
||||
sessionId: string,
|
||||
session: SessionLike,
|
||||
setters: ResumeStateSetter,
|
||||
buildRequest: (taskId: string, sessionId: string) => { request: import("@/lib/services/session-launch-service").LaunchSessionRequest },
|
||||
buildRequest: (
|
||||
taskId: string,
|
||||
sessionId: string,
|
||||
) => { request: import("@/lib/services/session-launch-service").LaunchSessionRequest },
|
||||
): Promise<void> {
|
||||
setters.setResumptionState("resuming");
|
||||
const { request } = buildRequest(taskId, sessionId);
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
|
||||
export type SessionIntent = "prepare" | "start" | "start_created" | "resume" | "workflow_step" | "restore_workspace";
|
||||
export type SessionIntent =
|
||||
| "prepare"
|
||||
| "start"
|
||||
| "start_created"
|
||||
| "resume"
|
||||
| "workflow_step"
|
||||
| "restore_workspace";
|
||||
|
||||
export type LaunchSessionRequest = {
|
||||
task_id: string;
|
||||
|
||||
@@ -37,7 +37,7 @@ export function getTerminalTheme(container: HTMLElement): ITheme {
|
||||
const v = (name: string) => s.getPropertyValue(name).trim();
|
||||
|
||||
return {
|
||||
background: v("--card"),
|
||||
background: v("--background"),
|
||||
foreground: v("--foreground"),
|
||||
cursor: v("--foreground"),
|
||||
cursorAccent: v("--background"),
|
||||
|
||||
@@ -92,11 +92,22 @@ function main() {
|
||||
// Current version has no changelog entry yet — fall back to the most recent released version
|
||||
const latest = findLatestVersionInChangelog(changelog);
|
||||
if (latest) {
|
||||
writeFileSync(OUTPUT_FILE, JSON.stringify({ version: latest.version, date: latest.date, notes: latest.notes }, null, 2) + "\n");
|
||||
console.log(`[release-notes] version ${version} not in CHANGELOG.md — fell back to ${latest.version}`);
|
||||
writeFileSync(
|
||||
OUTPUT_FILE,
|
||||
JSON.stringify(
|
||||
{ version: latest.version, date: latest.date, notes: latest.notes },
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
console.log(
|
||||
`[release-notes] version ${version} not in CHANGELOG.md — fell back to ${latest.version}`,
|
||||
);
|
||||
} else {
|
||||
writeFileSync(OUTPUT_FILE, JSON.stringify({ version, date: "", notes: "" }, null, 2) + "\n");
|
||||
console.log(`[release-notes] version ${version} not found in CHANGELOG.md — wrote empty notes`);
|
||||
console.log(
|
||||
`[release-notes] version ${version} not found in CHANGELOG.md — wrote empty notes`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user