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;
}