Files
codex-app-server/cloud/console-react/src/components/chat/Chat.tsx
T
host e90c885290 console: GitHub 风格(Primer)
保持一页式控制台排版与全部功能、API 不变,只换视觉:
深色顶栏 #24292f、Primer 浅色配色(边框 #d0d7de、链接蓝
#0969da、绿主按钮 #2da44e)、6px 圆角方框、系统字体与
等宽 ID/trace;任务列表改 issues 式(状态圆点 + 标题 +
灰色元信息);对话改评论时间线(白框 + 头部 + 正文,
竖线连接),灰/蓝气泡已移除;自动选项目与继续消息历史
不受影响。console 标记单测本地 53 项通过。
2026-10-29 10:13:30 +08:00

81 lines
3.1 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import type { ChatMessage } from "./mapTrace";
export function useAutoFollow(dep: unknown) {
const ref = useRef<HTMLDivElement>(null);
const [following, setFollowing] = useState(true);
const followingRef = useRef(true);
const onScroll = () => {
const el = ref.current;
if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
followingRef.current = nearBottom;
setFollowing(nearBottom);
};
useEffect(() => {
if (followingRef.current && ref.current) ref.current.scrollTop = ref.current.scrollHeight;
}, [dep]);
return { ref, following, onScroll };
}
// GitHub-style comment box, not a chat bubble: full-width white box
// with a subtle header row (role label only — no timestamp or sender
// is invented; the backend provides neither) and the body below.
// Status entries are plain muted timeline text on the timeline line.
export function ChatBubble({ msg }: { msg: ChatMessage }) {
if (msg.role === "status") {
return <div className="chat-status">{msg.text}</div>;
}
const author = msg.label || (msg.role === "user" ? "用户" : msg.role === "assistant" ? "助手" : "工具");
return (
<div className={"gh-comment " + msg.role}>
<div className="gh-comment-header"><span className="gh-comment-author">{author}</span></div>
<div className="gh-comment-body">{msg.text}</div>
</div>
);
}
export function ChatList({ messages, empty }: { messages: ChatMessage[]; empty?: string }) {
const { ref, following, onScroll } = useAutoFollow(messages.length);
return (
<div className="chat-list" ref={ref} onScroll={onScroll}>
{messages.length === 0 ? <div className="muted">{empty || "(暂无消息)"}</div> : messages.map((m) => <ChatBubble key={m.id} msg={m} />)}
{!following && messages.length > 0 ? (
<button className="chat-follow" onClick={() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }}>回到最新 ↓</button>
) : null}
</div>
);
}
export function ChatInput({ onSend, disabled, placeholder }: {
onSend: (text: string) => void; disabled?: boolean; placeholder?: string;
}) {
const [text, setText] = useState("");
const send = () => {
const v = text.trim();
if (!v || disabled) return;
onSend(v);
setText("");
};
// GitHub comment box: textarea on top, a subtle footer row with the
// green primary send button. No mic or attachment button here: the
// continue chat has no voice or attachment API (attachments live in
// Dispatch), so a mic/+ icon would be a dead button.
return (
<div className="gh-comment-box">
<textarea
aria-label="会话输入"
rows={3}
placeholder={placeholder || "消息"}
value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }}
/>
<div className="gh-comment-footer">
<button type="button" className="primary" aria-label="发送" disabled={disabled || !text.trim()} onClick={send}>发送</button>
</div>
</div>
);
}