From 3586d80055a2bd4911917818f86b41d9ad3efb17 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:59:53 -0500 Subject: [PATCH 1/2] test changes --- .../src/renderer/components/app/AppShell.tsx | 4 +- .../components/history/HistoryPage.tsx | 6 +- .../components/lanes/LaneInspectorPane.tsx | 14 +- .../renderer/components/lanes/LanesPage.tsx | 73 +--- .../renderer/components/packs/PackViewer.tsx | 336 ++---------------- .../renderer/components/prs/LanePrPanel.tsx | 3 + .../components/terminals/TerminalView.tsx | 27 +- .../components/terminals/TerminalsPage.tsx | 6 +- apps/desktop/src/renderer/state/appStore.ts | 2 +- 9 files changed, 91 insertions(+), 380 deletions(-) diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index ae49627c2..cec81749a 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -406,8 +406,8 @@ export function AppShell({ children }: { children: React.ReactNode }) { const laneId = aiFailure.laneId; if (!laneId) return; selectLane(laneId); - setLaneInspectorTab(laneId, "packs"); - window.location.hash = `#/lanes?laneId=${encodeURIComponent(laneId)}&focus=single&inspectorTab=packs`; + setLaneInspectorTab(laneId, "context"); + window.location.hash = `#/lanes?laneId=${encodeURIComponent(laneId)}&focus=single&inspectorTab=context`; }} title="Open lane packs" > diff --git a/apps/desktop/src/renderer/components/history/HistoryPage.tsx b/apps/desktop/src/renderer/components/history/HistoryPage.tsx index bdf6b14a4..b8d447f1f 100644 --- a/apps/desktop/src/renderer/components/history/HistoryPage.tsx +++ b/apps/desktop/src/renderer/components/history/HistoryPage.tsx @@ -334,10 +334,10 @@ export function HistoryPage() { ) : null} diff --git a/apps/desktop/src/renderer/components/lanes/LaneInspectorPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneInspectorPane.tsx index a4856a727..4fbaba5f3 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneInspectorPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneInspectorPane.tsx @@ -6,26 +6,26 @@ import { LanePrPanel } from "../prs/LanePrPanel"; import { LaneConflictsPanel } from "./LaneConflictsPanel"; const tabTrigger = - "px-2.5 py-1.5 text-xs font-semibold rounded-lg text-muted-fg transition-colors data-[state=active]:text-fg data-[state=active]:bg-accent/10"; + "px-3 py-1.5 text-xs font-semibold rounded-md cursor-pointer select-none text-muted-fg border border-transparent transition-all hover:text-fg hover:bg-white/5 data-[state=active]:text-fg data-[state=active]:bg-accent/15 data-[state=active]:border-border/30 data-[state=active]:shadow-sm"; export function LaneInspectorPane({ laneId, defaultTab }: { laneId: string | null; - defaultTab?: "packs" | "pr" | "conflicts"; + defaultTab?: "context" | "pr" | "conflicts"; }) { - const [tab, setTab] = useState<"packs" | "pr" | "conflicts">(defaultTab ?? "packs"); + const [tab, setTab] = useState<"context" | "pr" | "conflicts">(defaultTab ?? "context"); return ( setTab(v as "packs" | "pr" | "conflicts")} + onValueChange={(v) => setTab(v as "context" | "pr" | "conflicts")} className="flex h-full flex-col" > - - Packs + + Context PR @@ -35,7 +35,7 @@ export function LaneInspectorPane({
- + diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index b5805e556..782e496e2 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -95,16 +95,6 @@ function mergeUnique(...lists: string[][]): string[] { return out; } -function toggleFilterToken(query: string, token: string): string { - const normalizedToken = token.toLowerCase().trim(); - if (!normalizedToken.length) return query; - const tokens = query.trim().split(/\s+/).map((part) => part.toLowerCase()).filter(Boolean); - const next = new Set(tokens); - if (next.has(normalizedToken)) next.delete(normalizedToken); - else next.add(normalizedToken); - return Array.from(next).join(" "); -} - function matchesLaneFilterToken(lane: LaneSummary, isPinned: boolean, token: string): boolean { const normalized = token.trim().toLowerCase(); if (!normalized.length) return true; @@ -401,7 +391,7 @@ export function LanesPage() { const inspectorTab = params.get("inspectorTab"); if (laneId) { selectLane(laneId); - if (inspectorTab === "terminals" || inspectorTab === "packs" || inspectorTab === "stack" || inspectorTab === "merge") { + if (inspectorTab === "terminals" || inspectorTab === "context" || inspectorTab === "stack" || inspectorTab === "merge") { setLaneInspectorTab(laneId, inspectorTab); } if (params.get("focus") === "single") { @@ -552,10 +542,6 @@ export function LanesPage() { /* ---- Lane management actions ---- */ - const activeFilterTokens = useMemo(() => { - return new Set(laneFilter.trim().toLowerCase().split(/\s+/).filter(Boolean)); - }, [laneFilter]); - const currentPrimaryBranch = useMemo( () => primaryBranches.find((branch) => branch.isCurrent)?.name ?? primaryLane?.branchRef ?? "", [primaryBranches, primaryLane?.branchRef] @@ -927,50 +913,6 @@ export function LanesPage() { ) : null}
- - - - - -
- ) : null} + {ctxLane && ctxLane.laneType !== "primary" ? ( + + ) : null}
); })() : null} diff --git a/apps/desktop/src/renderer/components/packs/PackViewer.tsx b/apps/desktop/src/renderer/components/packs/PackViewer.tsx index b41e06f26..dc3e2df82 100644 --- a/apps/desktop/src/renderer/components/packs/PackViewer.tsx +++ b/apps/desktop/src/renderer/components/packs/PackViewer.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; -import * as Dialog from "@radix-ui/react-dialog"; -import { RefreshCw, Sparkles } from "lucide-react"; -import type { HostedBootstrapConfig, HostedJobStatusResult, HostedStatus, PackEvent, PackSummary, PackVersionSummary } from "../../../shared/types"; +import { RefreshCw } from "lucide-react"; +import type { HostedBootstrapConfig, HostedJobStatusResult, HostedStatus, PackSummary } from "../../../shared/types"; import { useAppStore } from "../../state/appStore"; import { Button } from "../ui/Button"; import { EmptyState } from "../ui/EmptyState"; @@ -41,52 +40,6 @@ function PackBody({ pack }: { pack: PackSummary | null }) { ); } -function formatPackEvent(ev: PackEvent): { title: string; detail: string; tone: "neutral" | "good" | "warn" | "bad" } { - const payload = ev.payload ?? {}; - const trigger = typeof payload.trigger === "string" ? payload.trigger : typeof payload.reason === "string" ? payload.reason : null; - const providerMode = typeof payload.providerMode === "string" ? payload.providerMode : null; - const error = typeof payload.error === "string" ? payload.error : null; - const jobId = typeof payload.jobId === "string" ? (payload.jobId as string) : null; - const jobStatus = typeof payload.status === "string" ? (payload.status as string) : null; - - if (ev.eventType === "refresh_triggered") { - return { title: "Pack refreshed", detail: trigger ? `trigger: ${trigger}` : "deterministic refresh", tone: "good" }; - } - if (ev.eventType === "narrative_requested") { - return { - title: "AI update requested", - detail: `${providerMode ? `provider: ${providerMode}` : "provider: ?"}${jobId ? ` · job ${shortId(jobId)}` : ""}${jobStatus ? ` · ${jobStatus}` : ""}${trigger ? ` · trigger: ${trigger}` : ""}`, - tone: "neutral" - }; - } - if (ev.eventType === "narrative_update") { - const provider = typeof payload.provider === "string" ? payload.provider : null; - const model = typeof payload.model === "string" ? payload.model : null; - const jobId = typeof payload.jobId === "string" ? payload.jobId : null; - const suffix = provider || model ? `${provider ?? "hosted"}${model ? ` · ${model}` : ""}` : jobId ? `job ${jobId}` : "updated"; - if (provider === "mock") { - return { - title: "AI details updated (mock)", - detail: "Hosted backend is in mock mode. Configure the hosted LLM secret/env to enable Gemini Flash.", - tone: "warn" - }; - } - return { title: "AI details updated", detail: suffix, tone: "good" }; - } - if (ev.eventType === "narrative_failed") { - const suffix = `${jobId ? `job ${shortId(jobId)} · ` : ""}${error ?? "unknown error"}`; - return { title: "AI update failed", detail: suffix, tone: "bad" }; - } - if (ev.eventType === "version_created") { - const vn = payload.versionNumber; - return { title: `Version saved${typeof vn === "number" ? ` (v${vn})` : ""}`, detail: "snapshot recorded", tone: "neutral" }; - } - if (ev.eventType === "checkpoint") { - return { title: "Checkpoint recorded", detail: "session boundary captured", tone: "neutral" }; - } - return { title: ev.eventType, detail: "", tone: "neutral" }; -} - function hostedReadiness( status: HostedStatus | null, error: string | null, @@ -117,6 +70,8 @@ function hostedReadiness( return null; } +const AI_COOLDOWN_MS = 30_000; + export function PackViewer({ laneId }: { laneId: string | null }) { const navigate = useNavigate(); const providerMode = useAppStore((s) => s.providerMode); @@ -126,7 +81,6 @@ export function PackViewer({ laneId }: { laneId: string | null }) { const [projectPack, setProjectPack] = useState(null); const [refreshBusy, setRefreshBusy] = useState(false); - const [aiBusy, setAiBusy] = useState(false); const [aiQueued, setAiQueued] = useState(false); const [error, setError] = useState(null); const [aiError, setAiError] = useState(null); @@ -137,25 +91,13 @@ export function PackViewer({ laneId }: { laneId: string | null }) { const [aiJob, setAiJob] = useState(null); - const [versionsDialogOpen, setVersionsDialogOpen] = useState(false); - const [versionsLoading, setVersionsLoading] = useState(false); - const [versions, setVersions] = useState([]); - const [fromVersionId, setFromVersionId] = useState(null); - const [toVersionId, setToVersionId] = useState(null); - const [diffBusy, setDiffBusy] = useState(false); - const [diffText, setDiffText] = useState(null); - - const [eventsDialogOpen, setEventsDialogOpen] = useState(false); - const [eventsLoading, setEventsLoading] = useState(false); - const [events, setEvents] = useState([]); - const activePack = scope === "project" ? projectPack : lanePack; - const activePackKey = activePack?.packKey ?? null; const activeMeta = (activePack?.metadata ?? null) as Record | null; const lanePackKey = laneId ? `lane:${laneId}` : null; const refreshTimers = useRef<{ lane?: number | null; project?: number | null }>({}); + const lastAiTriggerRef = useRef(0); const fetchLanePack = async () => { if (!laneId) return; @@ -185,7 +127,7 @@ export function PackViewer({ laneId }: { laneId: string | null }) { }, 120); }; - const refreshDeterministic = async () => { + const refreshCombined = async () => { setRefreshBusy(true); setError(null); try { @@ -196,8 +138,23 @@ export function PackViewer({ laneId }: { laneId: string | null }) { if (!laneId) return; const pack = await window.ade.packs.refreshLanePack(laneId); setLanePack(pack); - // Manual lane refresh also refreshes project pack in main. await fetchProjectPack().catch(() => {}); + + // Rate-limited AI narrative + const now = Date.now(); + if (providerMode !== "guest" && now - lastAiTriggerRef.current >= AI_COOLDOWN_MS) { + lastAiTriggerRef.current = now; + setAiQueued(true); + setAiError(null); + try { + const aiPack = await window.ade.packs.generateNarrative(laneId); + setLanePack(aiPack); + } catch (err) { + setAiError(err instanceof Error ? err.message : String(err)); + } finally { + setAiQueued(false); + } + } } } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -206,74 +163,6 @@ export function PackViewer({ laneId }: { laneId: string | null }) { } }; - const updateWithAi = async () => { - if (!laneId) return; - setAiBusy(true); - setAiQueued(true); - setAiError(null); - setError(null); - try { - const pack = await window.ade.packs.generateNarrative(laneId); - setLanePack(pack); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - setAiError(message); - } finally { - setAiBusy(false); - setAiQueued(false); - } - }; - - const openVersions = async () => { - if (!activePackKey) return; - setVersionsDialogOpen(true); - setVersionsLoading(true); - setDiffText(null); - setError(null); - try { - const list = await window.ade.packs.listVersions({ packKey: activePackKey, limit: 60 }); - setVersions(list); - setFromVersionId(list[1]?.id ?? list[0]?.id ?? null); - setToVersionId(list[0]?.id ?? null); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - setVersions([]); - } finally { - setVersionsLoading(false); - } - }; - - const runDiff = async () => { - if (!fromVersionId || !toVersionId) return; - if (fromVersionId === toVersionId) return; - setDiffBusy(true); - setError(null); - try { - const out = await window.ade.packs.diffVersions({ fromId: fromVersionId, toId: toVersionId }); - setDiffText(out.trim().length ? out : "(no diff)"); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setDiffBusy(false); - } - }; - - const openActivity = async () => { - if (!activePackKey) return; - setEventsDialogOpen(true); - setEventsLoading(true); - setError(null); - try { - const list = await window.ade.packs.listEvents({ packKey: activePackKey, limit: 120 }); - setEvents(list); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - setEvents([]); - } finally { - setEventsLoading(false); - } - }; - useEffect(() => { setLanePack(null); setProjectPack(null); @@ -404,9 +293,6 @@ export function PackViewer({ laneId }: { laneId: string | null }) { if (providerMode === "hosted") { const ready = hostedReadiness(hostedStatus, hostedError, aiJob); if (ready) return ready; - if (aiBusy && !aiJob) { - return { tone: "neutral" as const, message: "Submitting job…" }; - } if (aiJob?.jobId) { const elapsedSec = Math.max(0, Math.floor((Date.now() - aiJob.statusSinceMs) / 1000)); if (aiJob.status === "queued") { @@ -423,18 +309,18 @@ export function PackViewer({ laneId }: { laneId: string | null }) { } } if (aiQueued) { - return { tone: "neutral" as const, message: "AI update queued… (this will update automatically after pack refresh)." }; + return { tone: "neutral" as const, message: "AI update queued…" }; } - return { tone: "neutral" as const, message: "AI details update automatically after pack refresh. Use the button to re-run on demand." }; + return null; } if (providerMode === "byok") { if (aiQueued) { - return { tone: "neutral" as const, message: "AI update queued… (this will update automatically after pack refresh)." }; + return { tone: "neutral" as const, message: "AI update queued…" }; } - return { tone: "neutral" as const, message: "BYOK enabled. AI details update automatically after pack refresh (if configured). Use the button to re-run on demand." }; + return null; } return null; - }, [scope, providerMode, hostedStatus, hostedError, aiQueued, aiJob, aiBusy]); + }, [scope, providerMode, hostedStatus, hostedError, aiQueued, aiJob]); const aiMetaHint = useMemo(() => { if (scope !== "lane") return null; @@ -514,10 +400,10 @@ export function PackViewer({ laneId }: { laneId: string | null }) {
-
+
- - - {scope === "lane" ? ( - - ) : null} - - -
@@ -657,145 +526,6 @@ export function PackViewer({ laneId }: { laneId: string | null }) { {activePack?.path ?
{activePack.path}
: null} - - setVersionsDialogOpen(open)}> - - - -
- Pack Versions - - - -
- {versionsLoading ? ( -
Loading versions…
- ) : ( -
-
- {versions.length === 0 ? ( -
No versions recorded yet.
- ) : ( -
- {versions.map((v) => ( -
-
-
v{v.versionNumber}
-
{new Date(v.createdAt).toLocaleString()}
-
-
{v.contentHash.slice(0, 12)}
-
- - -
-
- ))} -
- )} -
-
-
-
Diff
- -
- {diffText ? ( -
-                      {diffText}
-                    
- ) : ( -
Select two versions and run diff.
- )} -
-
- )} -
-
-
- - setEventsDialogOpen(open)}> - - - -
- Activity - - - -
- {eventsLoading ? ( -
Loading activity…
- ) : events.length === 0 ? ( -
No activity recorded yet.
- ) : ( -
-
- {events.map((ev) => { - const formatted = formatPackEvent(ev); - const opId = typeof ev.payload?.operationId === "string" ? (ev.payload.operationId as string) : null; - return ( -
-
-
-
- {formatted.title} -
- {formatted.detail ?
{formatted.detail}
: null} -
-
-
{new Date(ev.createdAt).toLocaleString()}
- {opId ? ( - - ) : null} -
-
-
- ); - })} -
-
- )} -
-
-
); } diff --git a/apps/desktop/src/renderer/components/prs/LanePrPanel.tsx b/apps/desktop/src/renderer/components/prs/LanePrPanel.tsx index 8554c0d7e..e4892a669 100644 --- a/apps/desktop/src/renderer/components/prs/LanePrPanel.tsx +++ b/apps/desktop/src/renderer/components/prs/LanePrPanel.tsx @@ -417,6 +417,9 @@ export function LanePrPanel({ laneId }: { laneId: string | null }) { +
diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index 869f5a449..8ebaf5a0f 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -49,7 +49,7 @@ type AppState = { switchProjectToPath: (rootPath: string) => Promise; }; -export type LaneInspectorTab = "terminals" | "packs" | "stack" | "merge"; +export type LaneInspectorTab = "terminals" | "context" | "stack" | "merge"; export const useAppStore = create((set, get) => ({ project: null, From c7bdc66b101b7983f9114e3544d816006088ffef Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 18 Feb 2026 07:57:47 -0500 Subject: [PATCH 2/2] fixing terminal again --- .../components/lanes/LaneTerminalsPanel.tsx | 2 +- .../renderer/components/lanes/LanesPage.tsx | 142 ++++++++------- .../components/terminals/TerminalView.tsx | 162 +++++++++++------- 3 files changed, 184 insertions(+), 122 deletions(-) diff --git a/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx b/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx index 60507e742..8266a4b63 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx @@ -506,7 +506,7 @@ export function LaneTerminalsPanel({ overrideLaneId }: { overrideLaneId?: string - +
Terminal Settings diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 782e496e2..3e00f31b2 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useNavigate, useSearchParams } from "react-router-dom"; import * as Dialog from "@radix-ui/react-dialog"; import { Group, Panel } from "react-resizable-panels"; -import { Check, ChevronDown, FileCode2, GitBranch, Home, Layers3, Link2, Maximize2, Pin, Play, Plus, Search, Terminal, X } from "lucide-react"; +import { Check, ChevronDown, FileCode2, GitBranch, Home, Layers3, Link2, Maximize2, Pin, Plus, Search, Terminal, X } from "lucide-react"; import { useAppStore } from "../../state/appStore"; import { EmptyState } from "../ui/EmptyState"; import { cn } from "../ui/cn"; @@ -271,6 +271,9 @@ export function LanesPage() { const [branchCheckoutError, setBranchCheckoutError] = useState(null); const branchDropdownRef = useRef(null); + const [addLaneDropdownOpen, setAddLaneDropdownOpen] = useState(false); + const addLaneDropdownRef = useRef(null); + const [lanePaneDetails, setLanePaneDetails] = useState>({}); const [laneContextMenu, setLaneContextMenu] = useState<{ laneId: string; x: number; y: number } | null>(null); const [expandedLaneId, setExpandedLaneId] = useState(null); @@ -334,6 +337,17 @@ export function LanesPage() { return () => document.removeEventListener("mousedown", handler); }, [branchDropdownOpen]); + useEffect(() => { + if (!addLaneDropdownOpen) return; + const handler = (e: MouseEvent) => { + if (addLaneDropdownRef.current && !addLaneDropdownRef.current.contains(e.target as Node)) { + setAddLaneDropdownOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [addLaneDropdownOpen]); + /* ---- Conflict loading ---- */ const loadConflictStatuses = useCallback(async () => { @@ -913,49 +927,57 @@ export function LanesPage() { ) : null}
- - - + {/* Add Lane dropdown */} +
+ + {addLaneDropdownOpen ? ( +
+ + +
+ ) : null} +
- {filteredLanes.length}/{sortedLanes.length} · shift-click split · j/k move · [ ] cycle · / filter + {filteredLanes.length}/{sortedLanes.length} · shift-click split
@@ -977,7 +999,7 @@ export function LanesPage() { key={lane.id} type="button" className={cn( - "inline-flex max-w-[320px] shrink-0 items-center gap-1 rounded-xl px-2 py-1 text-xs transition-colors", + "group inline-flex max-w-[320px] shrink-0 items-center gap-1 rounded-xl px-2 py-1 text-xs transition-colors", isSelected ? "bg-accent/20 text-fg shadow-card ring-1 ring-accent/40 font-semibold" : isVisible @@ -997,9 +1019,7 @@ export function LanesPage() { > {isPrimary ? ( - ) : ( - - )} + ) : null} {lane.name} {isPrimary ? {lane.branchRef} : null} @@ -1020,8 +1040,10 @@ export function LanesPage() { {!isPrimary ? ( { event.stopPropagation(); @@ -1150,18 +1172,20 @@ export function LanesPage() { {/* Fullscreen lane overlay */} {expandedLaneId && lanesById.has(expandedLaneId) ? ( -
-
-
- {lanesById.get(expandedLaneId)?.name} - Fullscreen -
-
- +
Manage lane @@ -1281,7 +1305,7 @@ export function LanesPage() { {/* Create Lane dialog */} - +
Create lane @@ -1392,7 +1416,7 @@ export function LanesPage() { {/* Attach Lane dialog */} - +
Attach lane diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx index 1e523c948..9ed3ec55e 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx @@ -132,6 +132,8 @@ export function TerminalView({ ptyId, sessionId, className }: { ptyId: string; s return true; }; + let hasFittedOnce = false; + const doFit = () => { if (cancelled) return; if (!ensureOpen()) return; @@ -145,11 +147,18 @@ export function TerminalView({ ptyId, sessionId, className }: { ptyId: string; s } const next = { cols: term.cols, rows: term.rows }; if (!Number.isFinite(next.cols) || !Number.isFinite(next.rows) || next.cols <= 0 || next.rows <= 0) return; + hasFittedOnce = true; const prev = lastDimsRef.current; if (!prev || prev.cols !== next.cols || prev.rows !== next.rows) { lastDimsRef.current = next; window.ade.pty.resize({ ptyId, cols: next.cols, rows: next.rows }).catch(() => {}); } + // Force xterm to redraw all visible rows to prevent stale/garbled content. + try { + term.refresh(0, term.rows - 1); + } catch { + // Ignore if terminal was disposed. + } }; const scheduleFit = () => { @@ -161,25 +170,44 @@ export function TerminalView({ ptyId, sessionId, className }: { ptyId: string; s }); }; - // Allow layout to settle before first fit (helps in StrictMode/dev + tab switching). + // Allow layout to settle before first fit. Use staggered delays to + // handle complex layouts (PaneTilingLayout, route transitions) that + // may not have final dimensions on the first animation frame. + let settleTimer1: ReturnType | null = null; + let settleTimer2: ReturnType | null = null; initialRafId = requestAnimationFrame(() => { initialRafId = null; - scheduleFit(); + requestAnimationFrame(() => { + doFit(); + // Additional delayed fits to catch late layout settling after route changes. + settleTimer1 = setTimeout(() => { settleTimer1 = null; doFit(); }, 120); + settleTimer2 = setTimeout(() => { settleTimer2 = null; doFit(); }, 350); + }); }); - // Try to hydrate recent output so switching tabs doesn't feel like losing context. - window.ade.sessions - .readTranscriptTail({ sessionId, maxBytes: 80_000 }) - .then((text) => { - if (cancelled) return; - if (!text.trim().length) return; - try { - term.write(text); - } catch { - // Ignore writes after disposal/unmount. - } - }) - .catch(() => {}); + // Hydrate recent output AFTER initial fit so text wraps to correct column width. + // We wait until the first successful fit (hasFittedOnce) before writing, retrying + // briefly if layout hasn't settled yet. + const hydrateTranscript = () => { + window.ade.sessions + .readTranscriptTail({ sessionId, maxBytes: 80_000 }) + .then((text) => { + if (cancelled) return; + if (!text.trim().length) return; + try { + term.write(text); + // Redraw after hydration to ensure correct rendering. + requestAnimationFrame(() => { + try { term.refresh(0, term.rows - 1); } catch { /* ignore */ } + }); + } catch { + // Ignore writes after disposal/unmount. + } + }) + .catch(() => {}); + }; + // Wait a short moment for the initial fit to complete before hydrating. + const hydrateTimer = setTimeout(() => { if (!cancelled) hydrateTranscript(); }, 180); const dataSub = term.onData((data) => { if (cancelled) return; @@ -265,32 +293,62 @@ export function TerminalView({ ptyId, sessionId, className }: { ptyId: string; s const intObs = new IntersectionObserver((entries) => { for (const entry of entries) { - if (entry.isIntersecting) scheduleFit(); + if (entry.isIntersecting) { + // Double-RAF to let layout fully settle before refitting. + requestAnimationFrame(() => { + requestAnimationFrame(() => { doFit(); }); + }); + } } }); intObs.observe(el); - // Watch for visibility changes via CSS class toggling (invisible/pointer-events-none) + // Re-fit when app regains focus or document becomes visible again + // (handles navigating away from the tab and coming back). + const onVisibilityChange = () => { + if (cancelled || document.hidden) return; + requestAnimationFrame(() => { + requestAnimationFrame(() => { doFit(); }); + }); + }; + const onWindowFocus = () => { + if (cancelled) return; + requestAnimationFrame(() => { + requestAnimationFrame(() => { doFit(); }); + }); + }; + document.addEventListener("visibilitychange", onVisibilityChange); + window.addEventListener("focus", onWindowFocus); + + // Watch for visibility changes via CSS class toggling (invisible/pointer-events-none). + // Walk up to 4 ancestor levels to catch visibility toggling at any wrapper level + // (e.g. PaneTilingLayout panels, tab content wrappers, route containers). const mutObs = new MutationObserver(() => { if (cancelled) return; - // Check if our container is currently visible - const parentEl = el.parentElement; - if (!parentEl) return; - const isHidden = parentEl.classList.contains('invisible'); - if (!isHidden && el.isConnected && el.clientWidth > 0 && el.clientHeight > 0) { - // Terminal just became visible - schedule a double-RAF fit + // Check if our container is currently visible (no ancestor has 'invisible') + let ancestor: HTMLElement | null = el.parentElement; + while (ancestor) { + if (ancestor.classList.contains('invisible')) return; // still hidden + ancestor = ancestor.parentElement; + // Only walk a few levels to avoid perf cost + if (ancestor && ancestor === document.body) break; + } + if (el.isConnected && el.clientWidth > 0 && el.clientHeight > 0) { + // Terminal just became visible - schedule a staggered fit requestAnimationFrame(() => { - requestAnimationFrame(() => { - doFit(); - }); + requestAnimationFrame(() => { doFit(); }); }); } }); - // Observe the parent element for class changes - const parentEl = el.parentElement; - if (parentEl) { - mutObs.observe(parentEl, { attributes: true, attributeFilter: ['class'] }); + // Observe up to 4 ancestor elements for class changes to catch visibility toggling + // at any wrapper level in the component hierarchy. + const observedAncestors: HTMLElement[] = []; + let ancestor: HTMLElement | null = el.parentElement; + for (let depth = 0; depth < 4 && ancestor; depth++) { + observedAncestors.push(ancestor); + mutObs.observe(ancestor, { attributes: true, attributeFilter: ['class', 'style'] }); + ancestor = ancestor.parentElement; } termRef.current = term; @@ -299,40 +357,20 @@ export function TerminalView({ ptyId, sessionId, className }: { ptyId: string; s return () => { cancelled = true; - if (initialRafId != null) { - cancelAnimationFrame(initialRafId); - } - if (fitRafId != null) { - cancelAnimationFrame(fitRafId); - } - try { - unsubData(); - unsubExit(); - } catch { - // ignore - } - try { - dataSub.dispose(); - } catch { - // ignore - } - try { - obs.disconnect(); - } catch { - // ignore - } - try { - intObs.disconnect(); - } catch { - // ignore - } + if (initialRafId != null) cancelAnimationFrame(initialRafId); + if (fitRafId != null) cancelAnimationFrame(fitRafId); + if (settleTimer1 != null) clearTimeout(settleTimer1); + if (settleTimer2 != null) clearTimeout(settleTimer2); + clearTimeout(hydrateTimer); + document.removeEventListener("visibilitychange", onVisibilityChange); + window.removeEventListener("focus", onWindowFocus); + try { unsubData(); unsubExit(); } catch { /* ignore */ } + try { dataSub.dispose(); } catch { /* ignore */ } + try { obs.disconnect(); } catch { /* ignore */ } + try { intObs.disconnect(); } catch { /* ignore */ } try { mutObs.disconnect(); } catch { /* ignore */ } cancelViewportRaf(term); - try { - term.dispose(); - } catch { - // ignore - } + try { term.dispose(); } catch { /* ignore */ } termRef.current = null; fitRef.current = null; resizeObsRef.current = null;