保持一页式控制台排版与全部功能、API 不变,只换视觉: 深色顶栏 #24292f、Primer 浅色配色(边框 #d0d7de、链接蓝 #0969da、绿主按钮 #2da44e)、6px 圆角方框、系统字体与 等宽 ID/trace;任务列表改 issues 式(状态圆点 + 标题 + 灰色元信息);对话改评论时间线(白框 + 头部 + 正文, 竖线连接),灰/蓝气泡已移除;自动选项目与继续消息历史 不受影响。console 标记单测本地 53 项通过。
81 lines
3.1 KiB
TypeScript
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>
|
|
);
|
|
}
|