diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index c8ddf1e6ec..a89a27dd52 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -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') }} diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index ba35b6a350..054db84769 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -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 diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 7d386395b9..ecf678922a 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -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); } diff --git a/apps/web/components/kanban/graph2-step-node.tsx b/apps/web/components/kanban/graph2-step-node.tsx index 733987485f..d40ac30e36 100644 --- a/apps/web/components/kanban/graph2-step-node.tsx +++ b/apps/web/components/kanban/graph2-step-node.tsx @@ -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", )} >
{getTaskStateIcon(task.state, "h-3 w-3")}
- - {step.title} - + {step.title}
diff --git a/apps/web/components/task/chat/chat-input-body.tsx b/apps/web/components/task/chat/chat-input-body.tsx index d583d4f65a..5f6ccaff70 100644 --- a/apps/web/components/task/chat/chat-input-body.tsx +++ b/apps/web/components/task/chat/chat-input-body.tsx @@ -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", diff --git a/apps/web/components/task/passthrough-terminal.tsx b/apps/web/components/task/passthrough-terminal.tsx index 33cd8432df..dd6c9cee95 100644 --- a/apps/web/components/task/passthrough-terminal.tsx +++ b/apps/web/components/task/passthrough-terminal.tsx @@ -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 | null>; webglAddonRef: React.MutableRefObject; 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; attachAddonRef: React.MutableRefObject; isMountedCheck: () => boolean; - onInterval: (id: ReturnType) => void; + onTimeout: (id: ReturnType) => 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 | null = null; - let refreshInterval: ReturnType | null = null; + let settleTimeout: ReturnType | 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 (
); diff --git a/apps/web/components/task/shell-terminal.tsx b/apps/web/components/task/shell-terminal.tsx index 12d7d223a7..54b3047cfa 100644 --- a/apps/web/components/task/shell-terminal.tsx +++ b/apps/web/components/task/shell-terminal.tsx @@ -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, ); diff --git a/apps/web/hooks/domains/session/use-session-resumption.ts b/apps/web/hooks/domains/session/use-session-resumption.ts index af2494eb24..4cdfcb0827 100644 --- a/apps/web/hooks/domains/session/use-session-resumption.ts +++ b/apps/web/hooks/domains/session/use-session-resumption.ts @@ -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 { setters.setResumptionState("resuming"); const { request } = buildRequest(taskId, sessionId); diff --git a/apps/web/lib/services/session-launch-service.ts b/apps/web/lib/services/session-launch-service.ts index 1de93b1b7b..672975309e 100644 --- a/apps/web/lib/services/session-launch-service.ts +++ b/apps/web/lib/services/session-launch-service.ts @@ -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; diff --git a/apps/web/lib/theme/terminal-theme.ts b/apps/web/lib/theme/terminal-theme.ts index ac1070d2ee..931abf21c4 100644 --- a/apps/web/lib/theme/terminal-theme.ts +++ b/apps/web/lib/theme/terminal-theme.ts @@ -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"), diff --git a/apps/web/scripts/generate-release-notes.mjs b/apps/web/scripts/generate-release-notes.mjs index 1951198803..055ca468c8 100644 --- a/apps/web/scripts/generate-release-notes.mjs +++ b/apps/web/scripts/generate-release-notes.mjs @@ -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; }