Skip to content

Commit d1e8671

Browse files
authored
Merge pull request #18 from tyulyukov/marcode/explored-files-visibility
feat(chat): add exploration card for file reads and searches
2 parents 9dbd83d + 8da9e58 commit d1e8671

3 files changed

Lines changed: 164 additions & 1 deletion

File tree

apps/web/src/components/ChatView.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1987,7 +1987,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
19871987
);
19881988
if (!trigger || !scrollContainer.contains(trigger)) return;
19891989
if (trigger.closest("[data-scroll-anchor-ignore]")) {
1990-
trigger = trigger.parentElement?.closest<HTMLElement>("[data-scroll-anchor-target]") ?? null;
1990+
trigger =
1991+
trigger.parentElement?.closest<HTMLElement>("[data-scroll-anchor-target]") ?? null;
19911992
if (!trigger || !scrollContainer.contains(trigger)) return;
19921993
}
19931994

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { ChevronDownIcon, ChevronRightIcon, EyeIcon, SearchIcon } from "lucide-react";
2+
import { memo, useState } from "react";
3+
import { cn } from "~/lib/utils";
4+
import type { WorkLogEntry } from "../../session-logic";
5+
6+
interface ExplorationCardProps {
7+
entries: ReadonlyArray<WorkLogEntry>;
8+
isLive: boolean;
9+
}
10+
11+
const READ_LABEL_RE = /^Read\b/i;
12+
13+
function isReadEntry(entry: WorkLogEntry): boolean {
14+
return entry.requestKind === "file-read" || READ_LABEL_RE.test(entry.toolTitle ?? entry.label);
15+
}
16+
17+
function explorationEntryHeading(entry: WorkLogEntry): string {
18+
const raw = (entry.toolTitle ?? entry.label).trim();
19+
if (raw.length === 0) return "Explored";
20+
return `${raw.charAt(0).toUpperCase()}${raw.slice(1)}`;
21+
}
22+
23+
function ExplorationEntryRow(props: { entry: WorkLogEntry }) {
24+
const { entry } = props;
25+
const isRead = isReadEntry(entry);
26+
const Icon = isRead ? EyeIcon : SearchIcon;
27+
const heading = explorationEntryHeading(entry);
28+
const preview = entry.detail;
29+
30+
return (
31+
<div className="flex items-center gap-2 rounded-lg px-1 py-0.5">
32+
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground/50">
33+
<Icon className="size-3" />
34+
</span>
35+
<p className="min-w-0 flex-1 truncate text-[11px] leading-5 text-muted-foreground/70">
36+
<span className="text-foreground/70">{heading}</span>
37+
{preview && <span className="text-muted-foreground/45">{preview}</span>}
38+
</p>
39+
</div>
40+
);
41+
}
42+
43+
export const ExplorationCard = memo(function ExplorationCard(props: ExplorationCardProps) {
44+
const { entries, isLive } = props;
45+
const [expanded, setExpanded] = useState(false);
46+
47+
if (entries.length === 0) return null;
48+
49+
const readCount = entries.filter(isReadEntry).length;
50+
const searchCount = entries.length - readCount;
51+
52+
const headerParts: string[] = [];
53+
if (readCount > 0) headerParts.push(`${readCount} file${readCount !== 1 ? "s" : ""}`);
54+
if (searchCount > 0) headerParts.push(`${searchCount} search${searchCount !== 1 ? "es" : ""}`);
55+
const summary = headerParts.join(", ");
56+
57+
const verb = isLive ? "Exploring" : "Explored";
58+
const ToggleIcon = expanded ? ChevronDownIcon : ChevronRightIcon;
59+
60+
return (
61+
<div
62+
data-scroll-anchor-target
63+
className={cn(
64+
"overflow-hidden rounded-xl border border-border/40 border-l-2 bg-card/25",
65+
isLive ? "border-l-blue-400/40" : "border-l-blue-400/20",
66+
)}
67+
>
68+
<button
69+
type="button"
70+
onClick={() => setExpanded((prev) => !prev)}
71+
className="flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors duration-100 hover:bg-muted/20"
72+
>
73+
<ToggleIcon className="size-3 shrink-0 text-muted-foreground/50" />
74+
<SearchIcon className="size-3.5 shrink-0 text-blue-400/50" />
75+
<span className="min-w-0 flex-1 truncate text-[11px] text-foreground/80">
76+
{verb} {summary}
77+
</span>
78+
{isLive && <span className="size-1.5 shrink-0 animate-pulse rounded-full bg-blue-400/60" />}
79+
</button>
80+
81+
{expanded && (
82+
<div className="border-t border-border/20 px-2 py-1">
83+
{entries.map((entry) => (
84+
<ExplorationEntryRow key={entry.id} entry={entry} />
85+
))}
86+
</div>
87+
)}
88+
</div>
89+
);
90+
});

apps/web/src/components/chat/MessagesTimeline.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import {
6262
} from "./userMessageTerminalContexts";
6363
import { InlineDiffPreview } from "./InlineDiffPreview";
6464
import { FileChangeCard } from "./FileChangeCard";
65+
import { ExplorationCard } from "./ExplorationCard";
6566

6667
const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
6768

@@ -73,6 +74,7 @@ const DIFF_PREVIEW_MAX_HEIGHT = 260;
7374
const DIFF_HUNK_SPACING = 8;
7475
const AGENT_GROUP_HEADER_HEIGHT = 32;
7576
const FILE_CHANGE_CARD_COLLAPSED_HEIGHT = 64;
77+
const EXPLORATION_CARD_COLLAPSED_HEIGHT = 36;
7678

7779
type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];
7880
type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"];
@@ -105,6 +107,13 @@ type TimelineRow =
105107
createdAt: string;
106108
entry: TimelineWorkEntry;
107109
}
110+
| {
111+
kind: "exploration";
112+
id: string;
113+
createdAt: string;
114+
entries: TimelineWorkEntry[];
115+
isLive: boolean;
116+
}
108117
| { kind: "working"; id: string; createdAt: string | null };
109118

110119
interface TimelineRowContentProps {
@@ -213,6 +222,8 @@ const TimelineRowContent = memo(function TimelineRowContent({
213222

214223
{row.kind === "file-change" && <FileChangeCard diffPreviews={row.entry.diffPreviews ?? []} />}
215224

225+
{row.kind === "exploration" && <ExplorationCard entries={row.entries} isLive={row.isLive} />}
226+
216227
{row.kind === "message" &&
217228
row.message.role === "user" &&
218229
(() => {
@@ -564,6 +575,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
564575
let pendingWork: TimelineWorkEntry[] = [];
565576
let pendingWorkFirstId: string | null = null;
566577
let pendingWorkFirstCreatedAt: string | null = null;
578+
let pendingExploration: TimelineWorkEntry[] = [];
579+
let pendingExplorationFirstId: string | null = null;
580+
let pendingExplorationFirstCreatedAt: string | null = null;
567581
let cursor = index;
568582

569583
const flushPendingWork = () => {
@@ -580,6 +594,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({
580594
}
581595
};
582596

597+
const flushPendingExploration = () => {
598+
if (pendingExploration.length > 0) {
599+
nextRows.push({
600+
kind: "exploration",
601+
id: pendingExplorationFirstId!,
602+
createdAt: pendingExplorationFirstCreatedAt!,
603+
entries: pendingExploration,
604+
isLive: false,
605+
});
606+
pendingExploration = [];
607+
pendingExplorationFirstId = null;
608+
pendingExplorationFirstCreatedAt = null;
609+
}
610+
};
611+
583612
while (cursor < timelineEntries.length) {
584613
const current = timelineEntries[cursor];
585614
if (!current || current.kind !== "work") break;
@@ -590,13 +619,22 @@ export const MessagesTimeline = memo(function MessagesTimeline({
590619

591620
if (isFileChange) {
592621
flushPendingWork();
622+
flushPendingExploration();
593623
nextRows.push({
594624
kind: "file-change",
595625
id: current.id,
596626
createdAt: current.createdAt,
597627
entry: current.entry,
598628
});
629+
} else if (isExplorationEntry(current.entry)) {
630+
flushPendingWork();
631+
if (pendingExploration.length === 0) {
632+
pendingExplorationFirstId = current.id;
633+
pendingExplorationFirstCreatedAt = current.createdAt;
634+
}
635+
pendingExploration.push(current.entry);
599636
} else {
637+
flushPendingExploration();
600638
if (pendingWork.length === 0) {
601639
pendingWorkFirstId = current.id;
602640
pendingWorkFirstCreatedAt = current.createdAt;
@@ -606,6 +644,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
606644
cursor += 1;
607645
}
608646
flushPendingWork();
647+
flushPendingExploration();
609648
index = cursor - 1;
610649
continue;
611650
}
@@ -634,6 +673,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({
634673
}
635674

636675
if (isWorking) {
676+
for (let i = nextRows.length - 1; i >= 0; i--) {
677+
const r = nextRows[i];
678+
if (!r) break;
679+
if (r.kind === "exploration") {
680+
nextRows[i] = { ...r, isLive: true };
681+
break;
682+
}
683+
if (r.kind === "message" || r.kind === "proposed-plan") break;
684+
}
685+
637686
nextRows.push({
638687
kind: "working",
639688
id: "working-indicator-row",
@@ -719,6 +768,7 @@ function estimateRowHeight(
719768
if (row.kind === "proposed-plan") return estimateTimelineProposedPlanHeight(row.proposedPlan);
720769
if (row.kind === "working") return 40;
721770
if (row.kind === "file-change") return FILE_CHANGE_CARD_COLLAPSED_HEIGHT;
771+
if (row.kind === "exploration") return EXPLORATION_CARD_COLLAPSED_HEIGHT;
722772
return estimateTimelineMessageHeight(row.message, { timelineWidthPx });
723773
}
724774

@@ -954,6 +1004,28 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): {
9541004
};
9551005
}
9561006

1007+
const EXPLORATION_LABEL_RE =
1008+
/^(Read|Search(ed)?|Glob(bed)?|Grep(ped)?|List(ed)?|Find|Found|View(ed)?|Inspect(ed)?)\b/i;
1009+
1010+
function isExplorationEntry(entry: TimelineWorkEntry): boolean {
1011+
if (entry.requestKind === "file-change" || entry.requestKind === "command") return false;
1012+
if (
1013+
entry.itemType === "file_change" ||
1014+
entry.itemType === "command_execution" ||
1015+
entry.itemType === "web_search"
1016+
)
1017+
return false;
1018+
if (entry.command) return false;
1019+
if ((entry.diffPreviews?.length ?? 0) > 0) return false;
1020+
if (entry.agentGroup) return false;
1021+
1022+
if (entry.requestKind === "file-read") return true;
1023+
if (entry.itemType === "image_view") return true;
1024+
1025+
const heading = (entry.toolTitle ?? entry.label).trim();
1026+
return EXPLORATION_LABEL_RE.test(heading);
1027+
}
1028+
9571029
function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
9581030
if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";
9591031
if (tone === "tool") return "text-muted-foreground/70";

0 commit comments

Comments
 (0)