story-kit: merge 292_story_show_server_logs_in_web_ui

This commit is contained in:
Dave
2026-03-19 01:29:33 +00:00
parent 2346602b30
commit 2f0d796b38
6 changed files with 384 additions and 15 deletions

View File

@@ -85,7 +85,9 @@ export type WsResponse =
/** Streaming token from a /btw side question response. */
| { type: "side_question_token"; content: string }
/** Final signal that the /btw side question has been fully answered. */
| { type: "side_question_done"; response: string };
| { type: "side_question_done"; response: string }
/** A single server log entry (bulk on connect, then live). */
| { type: "log_entry"; timestamp: string; level: string; message: string };
export interface ProviderConfig {
provider: string;
@@ -376,6 +378,11 @@ export class ChatWebSocket {
private onOnboardingStatus?: (needsOnboarding: boolean) => void;
private onSideQuestionToken?: (content: string) => void;
private onSideQuestionDone?: (response: string) => void;
private onLogEntry?: (
timestamp: string,
level: string,
message: string,
) => void;
private connected = false;
private closeTimer?: number;
private wsPath = DEFAULT_WS_PATH;
@@ -461,6 +468,8 @@ export class ChatWebSocket {
this.onSideQuestionToken?.(data.content);
if (data.type === "side_question_done")
this.onSideQuestionDone?.(data.response);
if (data.type === "log_entry")
this.onLogEntry?.(data.timestamp, data.level, data.message);
if (data.type === "pong") {
window.clearTimeout(this.heartbeatTimeout);
this.heartbeatTimeout = undefined;
@@ -516,6 +525,7 @@ export class ChatWebSocket {
onOnboardingStatus?: (needsOnboarding: boolean) => void;
onSideQuestionToken?: (content: string) => void;
onSideQuestionDone?: (response: string) => void;
onLogEntry?: (timestamp: string, level: string, message: string) => void;
},
wsPath = DEFAULT_WS_PATH,
) {
@@ -533,6 +543,7 @@ export class ChatWebSocket {
this.onOnboardingStatus = handlers.onOnboardingStatus;
this.onSideQuestionToken = handlers.onSideQuestionToken;
this.onSideQuestionDone = handlers.onSideQuestionDone;
this.onLogEntry = handlers.onLogEntry;
this.wsPath = wsPath;
this.shouldReconnect = true;

View File

@@ -13,6 +13,8 @@ import { ChatInput } from "./ChatInput";
import { HelpOverlay } from "./HelpOverlay";
import { LozengeFlyProvider } from "./LozengeFlyContext";
import { MessageItem } from "./MessageItem";
import type { LogEntry } from "./ServerLogsPanel";
import { ServerLogsPanel } from "./ServerLogsPanel";
import { SideQuestionOverlay } from "./SideQuestionOverlay";
import { StagePanel } from "./StagePanel";
import { WorkItemDetailPanel } from "./WorkItemDetailPanel";
@@ -214,6 +216,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
loading: boolean;
} | null>(null);
const [showHelp, setShowHelp] = useState(false);
const [serverLogs, setServerLogs] = useState<LogEntry[]>([]);
// Ref so stale WebSocket callbacks can read the current queued messages
const queuedMessagesRef = useRef<{ id: string; text: string }[]>([]);
const queueIdCounterRef = useRef(0);
@@ -402,6 +405,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
prev ? { ...prev, response, loading: false } : prev,
);
},
onLogEntry: (timestamp, level, message) => {
setServerLogs((prev) => [...prev, { timestamp, level, message }]);
},
});
return () => {
@@ -1021,6 +1027,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
items={pipeline.backlog}
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
/>
<ServerLogsPanel logs={serverLogs} />
</>
)}
</LozengeFlyProvider>

View File

@@ -0,0 +1,246 @@
import * as React from "react";
const { useCallback, useEffect, useRef, useState } = React;
export interface LogEntry {
timestamp: string;
level: string;
message: string;
}
interface ServerLogsPanelProps {
logs: LogEntry[];
}
function levelColor(level: string): string {
switch (level.toUpperCase()) {
case "ERROR":
return "#e06c75";
case "WARN":
return "#e5c07b";
default:
return "#98c379";
}
}
export function ServerLogsPanel({ logs }: ServerLogsPanelProps) {
const [isOpen, setIsOpen] = useState(false);
const [filter, setFilter] = useState("");
const [severityFilter, setSeverityFilter] = useState<string>("ALL");
const scrollRef = useRef<HTMLDivElement>(null);
const userScrolledUpRef = useRef(false);
const lastScrollTopRef = useRef(0);
const filteredLogs = logs.filter((entry) => {
const matchesSeverity =
severityFilter === "ALL" || entry.level.toUpperCase() === severityFilter;
const matchesFilter =
filter === "" ||
entry.message.toLowerCase().includes(filter.toLowerCase()) ||
entry.timestamp.includes(filter);
return matchesSeverity && matchesFilter;
});
const scrollToBottom = useCallback(() => {
const el = scrollRef.current;
if (el) {
el.scrollTop = el.scrollHeight;
lastScrollTopRef.current = el.scrollTop;
}
}, []);
// Auto-scroll when new entries arrive (unless user scrolled up).
useEffect(() => {
if (!isOpen) return;
if (!userScrolledUpRef.current) {
scrollToBottom();
}
}, [filteredLogs.length, isOpen, scrollToBottom]);
const handleScroll = () => {
const el = scrollRef.current;
if (!el) return;
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 5;
if (el.scrollTop < lastScrollTopRef.current) {
userScrolledUpRef.current = true;
}
if (isAtBottom) {
userScrolledUpRef.current = false;
}
lastScrollTopRef.current = el.scrollTop;
};
const severityButtons = ["ALL", "INFO", "WARN", "ERROR"] as const;
return (
<div
data-testid="server-logs-panel"
style={{
borderRadius: "8px",
border: "1px solid #333",
overflow: "hidden",
}}
>
{/* Header / toggle */}
<button
type="button"
data-testid="server-logs-panel-toggle"
onClick={() => setIsOpen((v) => !v)}
style={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "8px 12px",
background: "#1e1e1e",
border: "none",
cursor: "pointer",
color: "#ccc",
fontSize: "0.85em",
fontWeight: 600,
textAlign: "left",
}}
>
<span>Server Logs</span>
<span style={{ color: "#666", fontSize: "0.85em" }}>
{logs.length > 0 && (
<span style={{ marginRight: "8px", color: "#555" }}>
{logs.length}
</span>
)}
{isOpen ? "▲" : "▼"}
</span>
</button>
{isOpen && (
<div style={{ background: "#0d1117" }}>
{/* Filter controls */}
<div
style={{
display: "flex",
gap: "6px",
padding: "8px",
borderBottom: "1px solid #1e1e1e",
flexWrap: "wrap",
alignItems: "center",
}}
>
<input
type="text"
data-testid="server-logs-filter-input"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter logs..."
style={{
flex: 1,
minWidth: "80px",
padding: "4px 8px",
borderRadius: "4px",
border: "1px solid #333",
background: "#161b22",
color: "#ccc",
fontSize: "0.8em",
outline: "none",
}}
/>
{severityButtons.map((sev) => (
<button
key={sev}
type="button"
data-testid={`server-logs-severity-${sev.toLowerCase()}`}
onClick={() => setSeverityFilter(sev)}
style={{
padding: "3px 8px",
borderRadius: "4px",
border: "1px solid",
borderColor:
severityFilter === sev ? levelColor(sev) : "#333",
background:
severityFilter === sev
? "rgba(255,255,255,0.06)"
: "transparent",
color:
sev === "ALL"
? severityFilter === "ALL"
? "#ccc"
: "#555"
: levelColor(sev),
fontSize: "0.75em",
cursor: "pointer",
fontWeight: severityFilter === sev ? 700 : 400,
}}
>
{sev}
</button>
))}
</div>
{/* Log entries */}
<div
ref={scrollRef}
onScroll={handleScroll}
data-testid="server-logs-entries"
style={{
maxHeight: "240px",
overflowY: "auto",
padding: "4px 0",
fontFamily: "monospace",
fontSize: "0.75em",
}}
>
{filteredLogs.length === 0 ? (
<div
style={{
padding: "16px",
color: "#444",
textAlign: "center",
fontSize: "0.9em",
}}
>
No log entries
</div>
) : (
filteredLogs.map((entry, idx) => (
<div
key={`${entry.timestamp}-${idx}`}
style={{
display: "flex",
gap: "6px",
padding: "1px 8px",
lineHeight: "1.5",
borderBottom: "1px solid #111",
}}
>
<span
style={{ color: "#444", flexShrink: 0, minWidth: "70px" }}
>
{entry.timestamp.replace("T", " ").replace("Z", "")}
</span>
<span
style={{
color: levelColor(entry.level),
flexShrink: 0,
minWidth: "38px",
fontWeight: 700,
}}
>
{entry.level}
</span>
<span
style={{
color: "#c9d1d9",
wordBreak: "break-word",
whiteSpace: "pre-wrap",
}}
>
{entry.message}
</span>
</div>
))
)}
</div>
</div>
)}
</div>
);
}