diff --git a/src-tauri/src/core/agent_chat.rs b/src-tauri/src/core/agent_chat.rs index 489be4c6..8ee39212 100644 --- a/src-tauri/src/core/agent_chat.rs +++ b/src-tauri/src/core/agent_chat.rs @@ -342,26 +342,45 @@ fn update_last_agent_message(chat: &mut AgentChat, screen: &str, timestamp: &str .last() .map(|m| m.role == ChatRole::User) .unwrap_or(true); - if chat + let previous_agent = chat .messages .iter() .rev() - .find(|m| m.role == ChatRole::Agent) - .is_some_and(|m| m.message == agent_message) - { + .take_while(|m| m.role == ChatRole::Agent) + .map(|m| m.message.as_str()) + .collect::>() + .into_iter() + .rev() + .collect::(); + if previous_agent == agent_message || agent_message.is_empty() { return; } + // Terminal snapshots are cumulative. Persist only the newly arrived portion + // so every screen update remains an individually timestamped log event. + let event_message = if !last_is_user { + agent_message + .strip_prefix(&previous_agent) + .map(trim_whitespace) + .filter(|message| !message.is_empty()) + .unwrap_or_else(|| agent_message.clone()) + } else { + agent_message + }; let conversation_message = ChatMessage { id: 0, role: ChatRole::Agent, - message: agent_message, + message: event_message, time: timestamp.to_string(), }; - if last_is_user || chat.messages.is_empty() { - chat.messages.push(conversation_message); - } else { + if chat + .messages + .last() + .is_some_and(|message| message.role == ChatRole::Agent && message.message.is_empty()) + { let last = chat.messages.len() - 1; chat.messages[last] = conversation_message; + } else { + chat.messages.push(conversation_message); } reindex(&mut chat.messages); } @@ -579,7 +598,7 @@ mod tests { } #[test] - fn later_screen_updates_replace_current_agent_message() { + fn later_screen_updates_create_separate_timestamped_messages() { let mut chat = sample_chat(); let before = "hi"; note_user_message(&mut chat, before, "hello", "t1"); @@ -590,14 +609,15 @@ mod tests { .iter() .filter(|m| m.role == ChatRole::Agent) .collect(); - assert_eq!(agents.last().unwrap().message, "partial reply done"); + assert_eq!(agents[agents.len() - 2].message, "partial"); + assert_eq!(agents.last().unwrap().message, "reply done"); assert_eq!( chat .messages .iter() .filter(|m| m.role == ChatRole::Agent) .count(), - 2 + 3 ); } diff --git a/src/components/LogFeed.test.tsx b/src/components/LogFeed.test.tsx new file mode 100644 index 00000000..a960302e --- /dev/null +++ b/src/components/LogFeed.test.tsx @@ -0,0 +1,70 @@ +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "../../test/test-utils"; +import type { LogRecordView } from "../lib/api-types"; +import { LogFeed } from "./LogFeed"; + +const records: LogRecordView[] = [ + { + timestamp: "2026-01-01T12:00:00Z", + severity_number: 9, + severity_text: "INFO", + body: "first searchable message", + trace_id: "trace", + span_id: "span", + run_id: 1, + job_id: "job", + step_index: 0, + step_name: "step", + stream: "stdout", + }, + { + timestamp: "2026-01-01T12:00:01Z", + severity_number: 9, + severity_text: "INFO", + body: "second message", + trace_id: "trace", + span_id: "span-2", + run_id: 1, + job_id: "job", + step_index: 0, + step_name: "step", + stream: "stdout", + }, +]; + +function feed(onSendToAgent = vi.fn()) { + render( + , + ); + return onSendToAgent; +} + +describe("LogFeed", () => { + it("shows inspection and send action only for one selected row", async () => { + const user = userEvent.setup(); + const send = feed(); + expect(screen.queryByTestId("send-to-agent")).toBeNull(); + await user.click(screen.getAllByTestId("line")[0]); + expect(screen.getByTestId("log-inspect-panel")).toHaveTextContent( + "first searchable message", + ); + await user.click(screen.getByTestId("send-to-agent")); + expect(send).toHaveBeenCalledWith([records[0]]); + }); + + it("opens VSCode-style find with Ctrl+F and highlights matches", async () => { + const user = userEvent.setup(); + feed(); + await user.keyboard("{Control>}f{/Control}"); + await user.type(screen.getByPlaceholderText("Find"), "searchable"); + expect(screen.getByText("searchable").tagName).toBe("MARK"); + expect(screen.getByText("1 of 1")).toBeTruthy(); + }); +}); diff --git a/src/components/LogFeed.tsx b/src/components/LogFeed.tsx index c98b9927..908f0afc 100644 --- a/src/components/LogFeed.tsx +++ b/src/components/LogFeed.tsx @@ -1,51 +1,37 @@ +import { useEffect, useMemo, useRef, useState } from "react"; import { Bot, CheckSquare, Square } from "lucide-react"; import { Button } from "./ui/button"; import type { LogRecordView } from "../lib/api-types"; import { cn } from "../lib/utils"; import { useLineSelection } from "../hooks/useLineSelection"; +import { SearchOverlay } from "./SearchOverlay"; -/** Info stays uncolored so warnings and errors are what draw the eye. */ export function severityClass(severityText: string): string { if (severityText === "ERROR") return "text-red-600 dark:text-red-400"; if (severityText === "WARN") return "text-amber-600 dark:text-amber-400"; return "text-foreground"; } - export function formatTimestamp(timestamp: string): string { const parsed = new Date(timestamp); if (Number.isNaN(parsed.getTime())) return timestamp; return parsed.toISOString().slice(11, 23); } - -/** Timestamps render as fixed HH:MM:SS.sss (12 chars), so the column can be sized exactly to fit. */ const TIMESTAMP_COL_CLASS = "w-[12ch] shrink-0 whitespace-nowrap"; -/** Longest severity name (e.g. "ERROR") plus a hair of padding. */ const LEVEL_COL_CLASS = "w-[6ch] shrink-0 whitespace-nowrap"; - export interface PrefixColumn { header: string; - /** Fixed-width class shared between the header cell and each row's cell. */ className: string; render: (record: LogRecordView) => React.ReactNode; } - interface Props { records: LogRecordView[]; - /** Extra fixed-width columns shown before the message; e.g. run/job ids. */ prefixColumns?: PrefixColumn[]; testId: string; lineTestId: string; emptyMessage: React.ReactNode; - /** Called with the chosen records when the user sends them to an agent. */ onSendToAgent: (records: LogRecordView[]) => void; } -/** - * Selectable log feed shared by the run and repo-wide browsers. - * - * Dragging across lines selects a range; multi-select mode turns clicks into - * individual toggles so non-adjacent lines can be gathered. - */ export function LogFeed({ records, prefixColumns = [], @@ -63,16 +49,86 @@ export function LogFeed({ toggleMultiSelect, selectAll, } = useLineSelection(records.length); + const feedRef = useRef(null); + const [findVisible, setFindVisible] = useState(false); + const [findQuery, setFindQuery] = useState(""); + const [activeMatch, setActiveMatch] = useState(0); + const [selectionToolbar, setSelectionToolbar] = useState<{ + text: string; + x: number; + y: number; + } | null>(null); + const matches = useMemo( + () => + findQuery.trim() + ? records.flatMap((record, index) => + record.body.toLowerCase().includes(findQuery.toLowerCase()) + ? [index] + : [], + ) + : [], + [findQuery, records], + ); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f") { + event.preventDefault(); + setFindVisible(true); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + useEffect(() => { + const onMouseUp = () => { + const selection = window.getSelection(); + const text = selection?.toString().trim() ?? ""; + if ( + !text || + !selection?.rangeCount || + !feedRef.current?.contains(selection.anchorNode) + ) { + setSelectionToolbar(null); + return; + } + const rect = selection.getRangeAt(0).getBoundingClientRect(); + setSelectionToolbar({ text, x: rect.left + rect.width / 2, y: rect.top }); + }; + document.addEventListener("mouseup", onMouseUp); + return () => document.removeEventListener("mouseup", onMouseUp); + }, []); - function handleSend() { - const chosen = Array.from(selected) + function chosenRecords() { + return Array.from(selected) .sort((a, b) => a - b) .map((index) => records[index]) .filter(Boolean); - if (chosen.length > 0) onSendToAgent(chosen); + } + function moveMatch(delta: number) { + if (!matches.length) return; + const next = (activeMatch + delta + matches.length) % matches.length; + setActiveMatch(next); + const matchEls = + feedRef.current?.querySelectorAll("[data-find-match]"); + matchEls?.[next]?.scrollIntoView({ block: "center" }); + } + function highlightedBody(body: string) { + if (!findQuery.trim()) return body; + const escaped = findQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return body.split(new RegExp(`(${escaped})`, "gi")).map((part, index) => + part.toLowerCase() === findQuery.toLowerCase() ? ( + + {part} + + ) : ( + part + ), + ); } - if (records.length === 0) { + if (!records.length) return (
); - } + const inspected = + selected.size === 1 ? records[Array.from(selected)[0]] : null; return (
@@ -116,19 +173,7 @@ export function LogFeed({ )} -
-
-
Timestamp {prefixColumns.map((col) => ( @@ -139,63 +184,117 @@ export function LogFeed({ Level Message
- -
- {records.map((record, index) => ( - - ))} + {prefixColumns.map((col) => ( + + {col.render(record)} + + ))} + + {record.severity_text} + + + {highlightedBody(record.body)} + +
+ ))} +
+ {inspected && ( + + )} + {selectionToolbar && ( + + )} ); } diff --git a/src/components/LogsTab.tsx b/src/components/LogsTab.tsx index 5df32678..56191d28 100644 --- a/src/components/LogsTab.tsx +++ b/src/components/LogsTab.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import useSWR from "swr"; -import { Bot, Database, Loader2, Table2 } from "lucide-react"; +import { Database, Loader2, Table2 } from "lucide-react"; import { Button } from "./ui/button"; import { getAgentChat, @@ -24,10 +24,7 @@ interface Props { type View = "browse" | "explorer"; type SourceGroup = "checks" | "agent-chats"; -/** - * Repo-level logs: checks (OpenTelemetry JSONL) and agent chats (TUI-split - * conversations from agent terminals, never shell terminals). - */ +/** Repo-level check and agent logs. */ export function LogsTab({ repoPath, onSendToAgent }: Props) { const [source, setSource] = useState("checks"); const [view, setView] = useState("browse"); @@ -59,45 +56,18 @@ export function LogsTab({ repoPath, onSendToAgent }: Props) { return (
-
-
- - -
-
-
- {checksSelected - ? "OpenTelemetry records · .treq/telemetry-*.db" - : "TUI-split conversations · .treq/agent-chats/*.json"} -
-
-
+ {checksSelected && (