Design pattern & component consistency governance across 120 files: ESLint governance (eslint.config.mjs): - Enforce ui/ components over native HTML controls (warn) - Ban Lucide imports (error), require Phosphor via ui/icon.tsx (warn) - Component file size limit 500 lines (warn, skip blanks+comments) - Patterns layer isolation (error: no hooks/lib imports) - Exclude 资料/ from lint scope Icon unification (ui/icon.tsx): - Unified barrel export with 86+ Phosphor icons - Decoupled types/index.ts from icon library (generic IconComponent type) - Separated command data (lib/constants/commands.ts) from icons (command-icons.ts) Color token system (globals.css): - Added semantic status tokens: success/warning/error/info with muted/foreground/border variants - Migrated 39 files from raw green/red/orange/amber/blue to semantic tokens - grep-based lint:colors script covers green/emerald/red/orange/yellow/amber/blue Component splits (all under 500 lines): - MessageInput 1659→444: MessageInputParts, SlashCommandPopover, 7 hooks, 2 libs - ChatView ~850→419: useStreamSubscription, useAssistantTrigger - ProviderManager 1081→406: provider-presets, PresetConnectDialog, useProviderModels - AppShell 527→470: useUpdateChecker - AssistantWorkspaceSection 908→499: WorkspaceTabPanels/ConfirmDialogs/StatusCards - ChatListPanel 677→484: SessionListItem, ProjectGroupHeader, chat-list-utils Other: - Migrated 80 native HTML controls to ui/ components across 38 files - Fixed 22 hook dependency / set-state-in-effect issues - Cleaned 7 dead code warnings - Rebuilt better-sqlite3 for ARM architecture - Added handover doc: docs/handover/ui-governance.md Lint status: 0 errors 0 warnings on touched files; typecheck pass; 374/374 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
196 lines
6.5 KiB
TypeScript
196 lines
6.5 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import { X } from "@/components/ui/icon";
|
|
import type { Message, MessagesResponse, ChatSession } from "@/types";
|
|
import { ChatView } from "@/components/chat/ChatView";
|
|
import { Button } from "@/components/ui/button";
|
|
import { usePanel } from "@/hooks/usePanel";
|
|
import { cn } from "@/lib/utils";
|
|
import { useTranslation } from "@/hooks/useTranslation";
|
|
|
|
interface SplitColumnProps {
|
|
sessionId: string;
|
|
isActive: boolean;
|
|
onClose: () => void;
|
|
onFocus: () => void;
|
|
}
|
|
|
|
export function SplitColumn({ sessionId, isActive, onClose, onFocus }: SplitColumnProps) {
|
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
const [hasMore, setHasMore] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [sessionTitle, setSessionTitle] = useState("");
|
|
const [sessionModel, setSessionModel] = useState("");
|
|
const [sessionProviderId, setSessionProviderId] = useState("");
|
|
const [sessionInfoLoaded, setSessionInfoLoaded] = useState(false);
|
|
const [sessionMode, setSessionMode] = useState("");
|
|
const [projectName, setProjectName] = useState("");
|
|
const [sessionWorkingDir, setSessionWorkingDir] = useState("");
|
|
const { setWorkingDirectory, setSessionId, setSessionTitle: setPanelSessionTitle, setPanelOpen } = usePanel();
|
|
const { t } = useTranslation();
|
|
|
|
// Load session metadata
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setSessionInfoLoaded(false);
|
|
setSessionModel("");
|
|
setSessionProviderId("");
|
|
async function loadSession() {
|
|
try {
|
|
const res = await fetch(`/api/chat/sessions/${sessionId}`);
|
|
if (cancelled) return;
|
|
if (res.ok) {
|
|
const data: { session: ChatSession } = await res.json();
|
|
setSessionTitle(data.session.title || t("chat.newConversation"));
|
|
setSessionModel(data.session.model || "");
|
|
setSessionProviderId(data.session.provider_id || "");
|
|
setSessionMode(data.session.mode || "code");
|
|
setProjectName(data.session.project_name || "");
|
|
setSessionWorkingDir(data.session.working_directory || "");
|
|
}
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
if (!cancelled) setSessionInfoLoaded(true);
|
|
}
|
|
}
|
|
loadSession();
|
|
return () => { cancelled = true; };
|
|
}, [sessionId, t]);
|
|
|
|
// Load messages
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
setError(null);
|
|
setMessages([]);
|
|
setHasMore(false);
|
|
|
|
async function loadMessages() {
|
|
try {
|
|
const res = await fetch(`/api/chat/sessions/${sessionId}/messages?limit=30`);
|
|
if (cancelled) return;
|
|
if (!res.ok) {
|
|
if (res.status === 404) {
|
|
setError("Session not found");
|
|
return;
|
|
}
|
|
throw new Error("Failed to load messages");
|
|
}
|
|
const data: MessagesResponse = await res.json();
|
|
if (cancelled) return;
|
|
setMessages(data.messages);
|
|
setHasMore(data.hasMore ?? false);
|
|
} catch (err) {
|
|
if (cancelled) return;
|
|
setError(err instanceof Error ? err.message : "Failed to load messages");
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
}
|
|
loadMessages();
|
|
return () => { cancelled = true; };
|
|
}, [sessionId]);
|
|
|
|
// When this column becomes active, sync PanelContext
|
|
useEffect(() => {
|
|
if (!isActive) return;
|
|
if (sessionWorkingDir) {
|
|
setWorkingDirectory(sessionWorkingDir);
|
|
localStorage.setItem("codepilot:last-working-directory", sessionWorkingDir);
|
|
window.dispatchEvent(new Event("refresh-file-tree"));
|
|
} else {
|
|
// Clear stale directory from previous column so FileTree doesn't show old project
|
|
setWorkingDirectory('');
|
|
}
|
|
setSessionId(sessionId);
|
|
setPanelOpen(true);
|
|
if (sessionTitle) {
|
|
setPanelSessionTitle(sessionTitle);
|
|
}
|
|
}, [isActive, sessionId, sessionWorkingDir, sessionTitle, setWorkingDirectory, setSessionId, setPanelSessionTitle, setPanelOpen]);
|
|
|
|
const handleClose = useCallback((e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onClose();
|
|
}, [onClose]);
|
|
|
|
if (loading || !sessionInfoLoaded) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex flex-1 min-w-0 flex-col overflow-hidden rounded-md border-2 transition-colors",
|
|
isActive ? "border-primary" : "border-transparent"
|
|
)}
|
|
onClick={onFocus}
|
|
>
|
|
<div className="flex h-full items-center justify-center">
|
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex flex-1 min-w-0 flex-col overflow-hidden rounded-md border-2 transition-colors",
|
|
isActive ? "border-primary" : "border-transparent"
|
|
)}
|
|
onClick={onFocus}
|
|
>
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-sm text-destructive">{error}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex flex-1 min-w-0 flex-col overflow-hidden rounded-md border-2 transition-colors",
|
|
isActive ? "border-primary" : "border-transparent"
|
|
)}
|
|
onClick={onFocus}
|
|
>
|
|
{/* Compact title bar */}
|
|
<div className="flex h-9 shrink-0 items-center justify-between px-3 border-b bg-muted/30">
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
{projectName && (
|
|
<>
|
|
<span className="text-[11px] text-muted-foreground shrink-0">{projectName}</span>
|
|
<span className="text-[11px] text-muted-foreground shrink-0">/</span>
|
|
</>
|
|
)}
|
|
<span className="text-[11px] font-medium truncate">{sessionTitle}</span>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
className="h-5 w-5 shrink-0 text-muted-foreground hover:text-foreground"
|
|
onClick={handleClose}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
<span className="sr-only">{t("split.closeSplit")}</span>
|
|
</Button>
|
|
</div>
|
|
{/* ChatView */}
|
|
<div className="flex-1 min-h-0 overflow-hidden">
|
|
<ChatView
|
|
key={sessionId}
|
|
sessionId={sessionId}
|
|
initialMessages={messages}
|
|
initialHasMore={hasMore}
|
|
modelName={sessionModel}
|
|
initialMode={sessionMode}
|
|
providerId={sessionProviderId}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|