Skip to content
Merged
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
72 changes: 71 additions & 1 deletion apps/web/src/components/SessionScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
Check,
ChevronDown,
Cpu,
FileDown,
FolderGit2,
Laptop,
Maximize2,
Expand All @@ -30,7 +31,7 @@ import {
Wifi,
X,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";

import type {
WorkspaceHost,
Expand All @@ -39,6 +40,13 @@ import type {
} from "../lib/workspace-data.ts";
import { PaneContent } from "../features/panes/PaneContent.tsx";
import { TerminalDrawer } from "../features/terminal/TerminalDrawer.tsx";
import {
transcriptFileName,
transcriptRowsToJson,
transcriptRowsToMarkdown,
type ExportContent,
type ExportMeta,
} from "../features/transcript/export.ts";
import { FreshnessBadge, SessionMain, SessionOwnershipBadge } from "../features/transcript/SessionMain.tsx";
import { RIGHT_PANE_DOCK_QUERY, useMediaQuery } from "../hooks/useMediaQuery.ts";
import { rendererPlatform, useWorkspace, workspaceStore } from "../state/store-instance.ts";
Expand All @@ -61,11 +69,13 @@ const FRESHNESS_LABEL = {

function SessionContextMenu({
host,
onExport,
onOpenHostHealth,
project,
session,
}: {
host: WorkspaceHost | undefined;
onExport: (format: "md" | "json") => void;
onOpenHostHealth: () => void;
project: WorkspaceProject;
session: WorkspaceSession;
Expand Down Expand Up @@ -133,6 +143,33 @@ function SessionContextMenu({
>
View host health
</button>
<div className="mt-1 border-border border-t px-1.5 pt-1.5 pb-0.5 text-[10px] text-muted-foreground uppercase tracking-wide">
Export transcript
</div>
<div className="flex gap-1">
<button
className="flex min-h-9 flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg text-muted-foreground text-xs outline-none transition-colors duration-(--motion-duration-fast) hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => {
setOpen(false);
onExport("md");
}}
type="button"
>
<FileDown aria-hidden="true" className="size-3.5" />
Markdown
</button>
<button
className="flex min-h-9 flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg text-muted-foreground text-xs outline-none transition-colors duration-(--motion-duration-fast) hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => {
setOpen(false);
onExport("json");
}}
type="button"
>
<FileDown aria-hidden="true" className="size-3.5" />
JSON
</button>
</div>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
Expand Down Expand Up @@ -275,6 +312,37 @@ export function SessionScreen({
const paneDocks = useMediaQuery(RIGHT_PANE_DOCK_QUERY);
const shellData = useShellData();
const host = shellData.hosts.find((entry) => entry.id === project.hostId);
// Filled by SessionMain with the current transcript rows; the context
// menu serializes whatever is there at click time.
const exportRowsRef = useRef<(() => ExportContent) | null>(null);
const handleExport = (format: "md" | "json") => {
const content = exportRowsRef.current?.();
if (content === null || content === undefined) return;
const exportedAt = new Date();
const meta: ExportMeta = {
sessionTitle: session.title,
projectName: project.name,
hostName: host?.name ?? "Unknown host",
model: session.model,
freshness: session.freshness,
exportedAt: exportedAt.toISOString(),
historyTruncated: content.historyTruncated,
turnActive: content.turnActive,
};
const text =
format === "json"
? transcriptRowsToJson(content.rows, meta)
: transcriptRowsToMarkdown(content.rows, meta);
const blob = new Blob([text], {
type: format === "json" ? "application/json" : "text/markdown",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = transcriptFileName(session.title, format, exportedAt);
anchor.click();
URL.revokeObjectURL(url);
};
const runtimeSnapshot = useDesktopRuntimeSnapshot();
const previewAddress =
runtimeSnapshot === null ? null : resolveLiveSession(runtimeSnapshot, session.id);
Expand Down Expand Up @@ -322,6 +390,7 @@ export function SessionScreen({
<span className="shrink-0 sm:min-w-0 sm:shrink">
<SessionContextMenu
host={host}
onExport={handleExport}
onOpenHostHealth={onOpenHostHealth}
project={project}
session={session}
Expand Down Expand Up @@ -374,6 +443,7 @@ export function SessionScreen({
<div className="min-h-0 flex-1 overflow-hidden">
<SessionMain
key={session.id}
exportRowsRef={exportRowsRef}
nowMs={nowMs}
onOpenHostHealth={onOpenHostHealth}
project={project}
Expand Down
19 changes: 17 additions & 2 deletions apps/web/src/features/transcript/SessionMain.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// container stays inert — this surface owns its own virtualized scroller.
import { useNavigate } from "@tanstack/react-router";
import { Badge, cn, Tooltip, TooltipPopup, TooltipTrigger, useReducedMotion } from "@t4-code/ui";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";

import type { WorkspaceProject, WorkspaceSession } from "../../lib/workspace-data.ts";
import { workspaceStore } from "../../state/store-instance.ts";
Expand Down Expand Up @@ -43,6 +43,7 @@ import {
shouldShowAttention,
type StableRowsState,
} from "./rows.ts";
import type { ExportContent } from "./export.ts";
import { getReadAloudController } from "./ReadAloud.tsx";
import { TranscriptTimeline } from "./TranscriptTimeline.tsx";
import type { ToolRenderHost } from "./tool-render/types.ts";
Expand All @@ -52,6 +53,8 @@ export interface SessionMainProps {
readonly project: WorkspaceProject;
readonly nowMs: number;
readonly onOpenHostHealth: () => void;
/** Export hook: registered with the current rows so the header menu can serialize them. */
readonly exportRowsRef: RefObject<(() => ExportContent) | null>;
}

/** Stable session-scoped destination; all transcript state stays in the workspace store. */
Expand Down Expand Up @@ -347,7 +350,7 @@ export function SessionControlBanner({
);
}

export function SessionMain({ onOpenHostHealth, session }: SessionMainProps) {
export function SessionMain({ onOpenHostHealth, session, exportRowsRef }: SessionMainProps) {
const archived = session.archivedAt !== undefined;
const navigate = useNavigate();
const { snapshot, runtime } = useSessionRuntime(session.id, session.freshness);
Expand Down Expand Up @@ -403,6 +406,18 @@ export function SessionMain({ onOpenHostHealth, session }: SessionMainProps) {
[projection, snapshot.pendingPrompts, snapshot.sessionActive],
);
const rows = useStableTranscriptRows(rawRows);
// The export menu lives in the header; rows live here. Hand the menu a
// getter instead of subscribing to the runtime a second time.
useEffect(() => {
exportRowsRef.current = () => ({
rows: rawRows,
historyTruncated: projection.historyTruncated,
turnActive: projection.turnActive,
});
return () => {
exportRowsRef.current = null;
};
}, [exportRowsRef, rawRows, projection]);
const attention = useMemo(() => deriveAttention(projection), [projection]);

const [revisingPlanId, setRevisingPlanId] = useState<string | null>(null);
Expand Down
Loading
Loading