Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "./TranscriptActivityGroup";
import { TranscriptToolView } from "./TranscriptToolView";
import { TranscriptReasoningView } from "./TranscriptReasoningView";
import { TranscriptAttachmentsDeliveredView } from "./TranscriptAttachmentsDeliveredView";
import { TranscriptStructuredEventView } from "./TranscriptStructuredEventView";
import { TranscriptFailureView } from "./TranscriptFailureView";
import {
Expand All @@ -40,6 +41,10 @@ type TranscriptEntry = ReturnType<typeof groupTranscriptMessages>[number];
type TranscriptContextEntry = Extract<TranscriptEntry, { kind: "context" }>;
type TranscriptFailureEntry = Extract<TranscriptEntry, { kind: "failure" }>;
type TranscriptMessageEntry = Extract<TranscriptEntry, { kind: "message" }>;
type TranscriptAttachmentsDeliveredEntry = Extract<
TranscriptEntry,
{ kind: "attachments_delivered" }
>;
type TranscriptStructuredEventEntry = Extract<
TranscriptEntry,
{ kind: "structured_event" }
Expand Down Expand Up @@ -171,6 +176,15 @@ function VisibleTranscriptEntries(props: {
/>
)
}
renderAttachmentsDelivered={(entry) => (
<TranscriptRailEvent kind="attachments_delivered">
<TranscriptAttachmentsDeliveredView
conversation={props.conversation}
part={entry.part}
timestamp={entry.timestamp}
/>
</TranscriptRailEvent>
)}
renderStructuredEvent={(entry) => (
<TranscriptRailEvent
icon={structuredEventIcon(entry.part.presentation.icon)}
Expand Down Expand Up @@ -211,6 +225,9 @@ function VisibleTranscriptEntries(props: {
function TranscriptEntryList(props: {
entries: TranscriptEntry[];
keyPrefix: string;
renderAttachmentsDelivered: (
entry: TranscriptAttachmentsDeliveredEntry,
) => ReactNode;
renderContext: (entry: TranscriptContextEntry) => ReactNode;
renderFailure: (entry: TranscriptFailureEntry) => ReactNode;
renderMessage: (entry: TranscriptMessageEntry) => ReactNode;
Expand All @@ -227,6 +244,9 @@ function TranscriptEntryList(props: {
const renderEntry = (entry: TranscriptEntry): ReactNode => {
if (entry.kind === "subagent") return props.renderSubagent(entry);
if (entry.kind === "context") return props.renderContext(entry);
if (entry.kind === "attachments_delivered") {
return props.renderAttachmentsDelivered(entry);
}
if (entry.kind === "structured_event") {
return props.renderStructuredEvent(entry);
}
Expand Down Expand Up @@ -359,6 +379,15 @@ function RedactedTranscriptView(props: {
/>
)
}
renderAttachmentsDelivered={(entry) => (
<TranscriptRailEvent kind="attachments_delivered">
<TranscriptAttachmentsDeliveredView
conversation={props.conversation}
part={entry.part}
timestamp={entry.timestamp}
/>
</TranscriptRailEvent>
)}
renderStructuredEvent={(entry) => (
<TranscriptRailEvent
icon={structuredEventIcon(entry.part.presentation.icon)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ export function activityGroupSummary(
const structuredCount = entries.filter(
(entry) => entry.kind === "structured_event",
).length;
const attachmentsCount = entries.filter(
(entry) => entry.kind === "attachments_delivered",
).length;
const resourceEventCount = entries.filter(
(entry) => entry.kind === "message",
).length;
Expand All @@ -114,6 +117,9 @@ export function activityGroupSummary(
structuredCount > 0
? countLabel(structuredCount, "1 structured event", "structured events")
: undefined,
attachmentsCount > 0
? countLabel(attachmentsCount, "1 file delivery", "file deliveries")
: undefined,
resourceEventCount > 0
? countLabel(resourceEventCount, "1 resource event", "resource events")
: undefined,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { Download, FileText, Image as ImageIcon } from "lucide-react";

import { formatMessageTimestamp } from "../format";
import type {
ConversationTranscript,
TranscriptViewAttachmentsDeliveredPart,
TranscriptViewDeliveredAttachment,
} from "../types";
import { HighlightText, useTranscriptSearch } from "./transcriptSearch";

function mayDisplayInline(contentType: string): boolean {
return (
contentType === "image/gif" ||
contentType === "image/jpeg" ||
contentType === "image/png" ||
contentType === "image/webp"
);
}

function attachmentUrl(
conversationId: string,
attachmentId: string,
): string {
return `/api/conversations/${encodeURIComponent(conversationId)}/attachments/${encodeURIComponent(attachmentId)}`;
}

function formatAttachmentBytes(bytes: number | undefined): string | undefined {
if (bytes === undefined) return undefined;
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

function AttachmentCard(props: {
attachment: TranscriptViewDeliveredAttachment;
conversationId: string;
}) {
const search = useTranscriptSearch();
const href = attachmentUrl(props.conversationId, props.attachment.id);
const size = formatAttachmentBytes(props.attachment.bytes);
const inline = mayDisplayInline(props.attachment.contentType);

return (
<div className="min-w-0 overflow-hidden rounded-md border border-white/10 bg-white/[0.03]">
{inline && !search.active ? (
<a
className="block bg-black/20"
href={href}
rel="noreferrer"
target="_blank"
>
<img
alt={props.attachment.name}
className="max-h-80 w-full object-contain"
loading="lazy"
src={href}
/>
</a>
) : null}
<div className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 px-3 py-2">
<span
aria-hidden="true"
className="grid size-7 place-items-center rounded bg-black/25 text-dashboard-text-muted"
>
{inline ? <ImageIcon size={13} /> : <FileText size={13} />}
</span>
<div className="min-w-0">
<div className="truncate font-mono text-xs text-dashboard-text">
<HighlightText text={props.attachment.name} />
</div>
<div className="truncate font-mono text-2xs text-dashboard-text-muted">
<HighlightText
text={[props.attachment.contentType, size]
.filter((value): value is string => value !== undefined)
.join(" · ")}
/>
</div>
</div>
<a
className="inline-flex items-center gap-1 rounded border border-white/10 px-2 py-1 font-mono text-2xs text-dashboard-text-muted no-underline hover:border-white/25 hover:text-dashboard-text"
download={props.attachment.name}
href={href}
rel="noreferrer"
>
<Download size={11} />
download
</a>
</div>
</div>
);
}

/** Render host-delivered conversation attachments as first-class transcript media. */
export function TranscriptAttachmentsDeliveredView(props: {
conversation: ConversationTranscript;
part: TranscriptViewAttachmentsDeliveredPart;
timestamp?: number;
}) {
const timestamp = formatMessageTimestamp(props.timestamp);
const count = props.part.attachments.length;
const title = count === 1 ? "1 file delivered" : `${count} files delivered`;

return (
<div className="min-w-0 rounded-md bg-sky-300/[0.07] px-2.5 py-1.5 font-mono text-xs leading-tight">
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-2 max-md:grid-cols-[minmax(0,1fr)]">
<div className="font-display text-sm font-semibold text-sky-100">
<HighlightText text={title} />
</div>
{timestamp ? (
<span className="font-mono text-xs text-dashboard-text-muted max-md:hidden">
{timestamp}
</span>
) : null}
</div>
<div className="mt-2 grid gap-2">
{props.part.attachments.map((attachment) => (
<AttachmentCard
attachment={attachment}
conversationId={props.conversation.conversationId}
key={attachment.id}
/>
))}
</div>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Link,
MessageSquareText,
Minimize2,
Paperclip,
Send,
Sparkles,
TriangleAlert,
Expand All @@ -22,6 +23,7 @@ import { cn } from "../styles";
import type { TranscriptViewStructuredEventPart } from "../types";

type TranscriptRailEventKind =
| "attachments_delivered"
| "compaction"
| "handoff"
| "message_context"
Expand Down Expand Up @@ -73,6 +75,12 @@ function transcriptRailMarker(kind: TranscriptRailEventKind): {
icon: Diff,
};
}
if (kind === "attachments_delivered") {
return {
className: "text-sky-200",
icon: Paperclip,
};
}
if (kind === "structured_event") {
return {
className: "text-violet-200",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,19 @@ export function conversationTranscriptMessages(
continue;
}

if (data.type === "attachments_delivered") {
messages.push(
eventMessage(event, "system", [
{
type: "attachments_delivered",
attachments: data.attachments,
...(data.toolCallId ? { toolCallId: data.toolCallId } : {}),
},
]),
);
continue;
}

if (data.type === "compaction" || data.type === "handoff") {
messages.push(
eventMessage(event, "system", [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,16 @@ function transcriptPartVersion(part: TranscriptViewPart | undefined): string {
part.presentation.details?.length ?? 0,
].join(":");
}
if (part.type === "attachments_delivered") {
return [
part.type,
part.toolCallId ?? "",
...part.attachments.map(
(attachment) =>
`${attachment.id}:${attachment.name}:${attachment.contentType}:${attachment.bytes ?? ""}`,
),
].join(":");
}
return [part.type, part.event.type, part.event.createdAt].join(":");
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
TranscriptViewAttachmentsDeliveredPart,
TranscriptViewContextEventPart,
TranscriptViewMessage,
TranscriptViewStructuredEventPart,
Expand All @@ -15,6 +16,13 @@ export type RenderedFailureEntry = {
timestamp?: number;
};

export type RenderedAttachmentsDeliveredEntry = {
key: string;
kind: "attachments_delivered";
part: TranscriptViewAttachmentsDeliveredPart;
timestamp?: number;
};

export type RenderedContextEventEntry = {
key: string;
kind: "context";
Expand Down Expand Up @@ -59,6 +67,7 @@ export type RenderedMessageEntry = {
};

export type RenderedTranscriptEntry =
| RenderedAttachmentsDeliveredEntry
| RenderedContextEventEntry
| RenderedFailureEntry
| RenderedMessageEntry
Expand Down Expand Up @@ -124,6 +133,13 @@ export function groupTranscriptMessages(
part,
timestamp: message.timestamp,
});
} else if (part.type === "attachments_delivered") {
entries.push({
key: `${message.sourceSeq}:attachments-delivered`,
kind: "attachments_delivered",
part,
timestamp: message.timestamp,
});
} else {
entries.push({
key: `${message.sourceSeq}:context:${partIndex}`,
Expand Down Expand Up @@ -172,6 +188,11 @@ export function messageRawText(message: TranscriptViewMessage): string {
.filter((line): line is string => line !== undefined)
.join("\n");
}
if (part.type === "attachments_delivered") {
return part.attachments
.map((attachment) => attachment.name)
.join("\n");
}
if (part.event.type !== "handoff") {
return ["context compacted", part.event.summary]
.filter((line): line is string => line !== undefined)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,14 @@ export function entryMatchesSearch(
].some((value) => textContains(value, normalizedQuery));
}

if (entry.kind === "attachments_delivered") {
return entry.part.attachments.some(
(attachment) =>
textContains(attachment.name, normalizedQuery) ||
textContains(attachment.contentType, normalizedQuery),
);
}

if (entry.kind === "context") {
const event = entry.part.event;
return event.type === "handoff"
Expand Down
16 changes: 16 additions & 0 deletions packages/junior-dashboard/src/client/markdownExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,22 @@ function appendTranscriptMessages(
continue;
}

if (entry.kind === "attachments_delivered") {
const count = entry.part.attachments.length;
lines.push(
"",
`### ${count === 1 ? "1 file delivered" : `${count} files delivered`}`,
);
addEventMeta(lines, conversationTranscript, entry.timestamp);
for (const attachment of entry.part.attachments) {
lines.push(
"",
`- ${attachment.name} (${attachment.contentType}${attachment.bytes !== undefined ? `, ${attachment.bytes} bytes` : ""})`,
);
}
continue;
}

appendTool(lines, conversationTranscript, entry.part, entry.timestamp);
}
}
Expand Down
14 changes: 14 additions & 0 deletions packages/junior-dashboard/src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,21 @@ export type TranscriptViewStructuredEventPart = {
version: number;
};

export type TranscriptViewDeliveredAttachment = {
bytes?: number;
contentType: string;
id: string;
name: string;
};

export type TranscriptViewAttachmentsDeliveredPart = {
attachments: TranscriptViewDeliveredAttachment[];
toolCallId?: string;
type: "attachments_delivered";
};

export type TranscriptViewPart =
| TranscriptViewAttachmentsDeliveredPart
| TranscriptViewContextEventPart
| TranscriptViewStructuredEventPart
| TranscriptViewReasoningPart
Expand Down
Loading
Loading