fix: disable terminal UI, fix hydration mismatch

- Remove terminal button from UnifiedTopBar and TerminalDrawer rendering
  Terminal feature disabled until proper node-pty + xterm.js integration
- Fix ChatListPanel hydration mismatch: chatListOpenRaw/chatListWidth
  used window-dependent initializers causing SSR/client divergence
- Improve TerminalInstance for future use: stable useEffect deps,
  ANSI rendering via ansi-to-react, focus management
- Clear CLAUDECODE env var in terminal-manager to allow nested sessions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-13 02:26:57 +08:00
1 parent 4326269817
commit 132bfcebc0
6 files changed
+77 -64

No files matched your search

+17 -21
View File
@@ -17,7 +17,6 @@ import { ImageGenContext, useImageGenState } from "@/hooks/useImageGen";
import { BatchImageGenContext, useBatchImageGenState } from "@/hooks/useBatchImageGen";
import { SplitContext, type SplitSession } from "@/hooks/useSplit";
import { SplitChatContainer } from "./SplitChatContainer";
import { TerminalDrawer } from "@/components/terminal/TerminalDrawer";
import { ErrorBoundary } from "./ErrorBoundary";
import { getActiveSessionIds, getSnapshot } from "@/lib/stream-session-manager";
import { useGitStatus } from "@/hooks/useGitStatus";
@@ -69,16 +68,25 @@ export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const [chatListOpenRaw, setChatListOpenRaw] = useState(() => {
if (typeof window === "undefined") return false;
return window.matchMedia(`(min-width: ${LG_BREAKPOINT}px)`).matches;
});
const [chatListOpenRaw, setChatListOpenRaw] = useState(false);
// Sync with viewport after hydration to avoid SSR mismatch
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
setChatListOpenRaw(window.matchMedia(`(min-width: ${LG_BREAKPOINT}px)`).matches);
}, []);
/* eslint-enable react-hooks/set-state-in-effect */
// Panel width state with localStorage persistence
const [chatListWidth, setChatListWidth] = useState(() => {
if (typeof window === "undefined") return 240;
return parseInt(localStorage.getItem("codepilot_chatlist_width") || "240");
});
const [chatListWidth, setChatListWidth] = useState(240);
// Restore persisted width after hydration
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
const saved = localStorage.getItem("codepilot_chatlist_width");
if (saved) setChatListWidth(parseInt(saved));
}, []);
/* eslint-enable react-hooks/set-state-in-effect */
const handleChatListResize = useCallback((delta: number) => {
setChatListWidth((w) => Math.min(CHATLIST_MAX, Math.max(CHATLIST_MIN, w + delta)));
@@ -305,17 +313,6 @@ export function AppShell({ children }: { children: React.ReactNode }) {
return () => mql.removeEventListener("change", handler);
}, []);
// Ctrl+` / Cmd+` toggles terminal drawer
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === '`' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
setTerminalOpen((prev) => !prev);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
// --- Skip-permissions indicator ---
const [skipPermissionsActive, setSkipPermissionsActive] = useState(false);
@@ -417,7 +414,6 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<ErrorBoundary>{children}</ErrorBoundary>
)}
</main>
{isChatDetailRoute && <TerminalDrawer />}
</div>
{isChatDetailRoute && <PanelZone />}
</div>
-18
View File
@@ -7,7 +7,6 @@ import {
GitCommit,
CloudArrowUp,
TreeStructure,
Terminal,
PencilSimple,
DotOutline,
CaretDown,
@@ -39,8 +38,6 @@ export function UnifiedTopBar() {
setFileTreeOpen,
gitPanelOpen,
setGitPanelOpen,
terminalOpen,
setTerminalOpen,
currentBranch,
gitDirtyCount,
} = usePanel();
@@ -291,21 +288,6 @@ export function UnifiedTopBar() {
<TooltipContent side="bottom">{t('topBar.git')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={terminalOpen ? "secondary" : "ghost"}
size="icon-sm"
className={terminalOpen ? "" : "text-muted-foreground hover:text-foreground"}
onClick={() => setTerminalOpen(!terminalOpen)}
>
<Terminal size={16} />
<span className="sr-only">{t('topBar.terminal')}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('topBar.terminal')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
+55 -22
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState, useCallback } from "react";
import Ansi from "ansi-to-react";
import type { useTerminal } from "@/hooks/useTerminal";
interface TerminalInstanceProps {
@@ -8,54 +9,86 @@ interface TerminalInstanceProps {
}
/**
* TerminalInstance — renders terminal output as a simple text view.
* TerminalInstance — renders terminal output with ANSI color support.
*
* Phase 4 v1: Uses a simple <pre> element for output display.
* xterm.js integration can be added later for full terminal emulation
* (requires npm install xterm @xterm/addon-fit).
* Uses ansi-to-react for ANSI escape code rendering.
* xterm.js integration can be added later for full terminal emulation.
*/
export function TerminalInstance({ terminal }: TerminalInstanceProps) {
const outputRef = useRef<HTMLPreElement>(null);
const { isElectron, connected, exited, create, write, setOnData } = terminal;
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const outputTextRef = useRef("");
const [output, setOutput] = useState("");
const bufferRef = useRef("");
const rafRef = useRef<number | null>(null);
// Flush buffered output via rAF to avoid excessive re-renders
const flush = useCallback(() => {
rafRef.current = null;
setOutput(bufferRef.current);
}, []);
// Subscribe to PTY output
useEffect(() => {
terminal.setOnData((data: string) => {
outputTextRef.current += data;
if (outputRef.current) {
outputRef.current.textContent = outputTextRef.current;
outputRef.current.scrollTop = outputRef.current.scrollHeight;
setOnData((data: string) => {
bufferRef.current += data;
if (rafRef.current === null) {
rafRef.current = requestAnimationFrame(flush);
}
});
}, [terminal]);
return () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
};
}, [setOnData, flush]);
// Auto-scroll on new output
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [output]);
// Create terminal on mount
useEffect(() => {
if (terminal.isElectron && !terminal.connected && !terminal.exited) {
terminal.create(120, 30);
if (isElectron && !connected && !exited) {
create(120, 30);
}
}, [terminal]);
}, [isElectron, connected, exited, create]);
// Focus input when terminal connects
useEffect(() => {
inputRef.current?.focus();
}, [connected]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
const value = inputRef.current?.value || '';
terminal.write(value + '\n');
write(value + '\n');
if (inputRef.current) inputRef.current.value = '';
}
};
const handleContainerClick = () => {
inputRef.current?.focus();
};
return (
<div className="flex flex-col h-full bg-[#1a1a1a] text-[#d4d4d4] font-mono text-xs">
<pre
ref={outputRef}
<div
className="flex flex-col h-full bg-[#1a1a1a] text-[#d4d4d4] font-mono text-xs"
onClick={handleContainerClick}
>
<div
ref={scrollRef}
className="flex-1 overflow-auto p-2 whitespace-pre-wrap break-all"
/>
>
<Ansi>{output}</Ansi>
</div>
<div className="flex items-center border-t border-[#333] px-2">
<span className="text-green-400 mr-1">$</span>
<input
ref={inputRef}
type="text"
className="flex-1 bg-transparent border-none outline-none text-xs py-1.5 text-[#d4d4d4]"
className="flex-1 bg-transparent border-none outline-none text-xs py-1.5 text-[#d4d4d4] caret-[#d4d4d4]"
onKeyDown={handleKeyDown}
autoFocus
spellCheck={false}