Repository navigation
feat(conversations): Add transcript export - #127364
ArthurKnaus merged 11 commits into
Conversation
Replace the transcript Copy button with an Export transcript dropdown that copies the transcript to the clipboard or downloads it as Markdown. The transcript is built from the whole conversation: the already-cached pages are reused instantly when the detail view has them, otherwise every page is fetched on demand with a loading state.
📊 Type Coverage Diff
🔍 7 new type safety issues introducedNon-null assertions (
Type assertions (
| This is informational only and does not block the PR. |
Keep the export request alive when the button unmounts mid-flight (switching off the transcript tab) so a large-conversation export no longer aborts silently. Drop the deprecated loading toast in favor of the button's busy state, and type the paginated response instead of casting it.
Annotate the paginated response as ApiResult<ConversationApiResponse> rather than a hand-written tuple that was not assignable to the client's return type.
| const {selection} = usePageFilters(); | ||
| const [isExporting, setIsExporting] = useState(false); | ||
|
|
||
| const fetchConversation = useCallback(async (): Promise<ConversationExport> => { |
There was a problem hiding this comment.
Bot review from Codex.
Could we share the infinite-query options with useConversation and pick up where it left off? This refetches from page one unless everything is already loaded. I tested a separate, seeded InfiniteQueryObserver: it fetched just the missing pages and left the display cache alone.
Something like this, with options extracted from the display query:
const fetchConversation = useCallback(async () => {
const observer = new InfiniteQueryObserver(queryClient, {
...options,
queryKey: [
options.queryKey[0],
options.queryKey[1],
{...options.queryKey[2], export: true},
],
initialData: queryClient.getQueryData(options.queryKey),
});
let result = observer.getCurrentResult();
try {
while (!result.data || result.hasNextPage) {
result = await observer.fetchNextPage({throwOnError: true});
}
} catch (error) {
observer.destroy();
throw error;
}
observer.destroy();
return result.data.pages; // Build the transcript from these.
}, [queryClient, options]);That would also reuse the existing cursor parsing.
Build the transcript from a separate InfiniteQueryObserver seeded with the display query's cached pages, so the export resumes where the display left off and fetches only the missing pages rather than refetching from page one. It reuses the shared query options and their cursor parsing, runs on the app-wide query client (so leaving the transcript tab no longer aborts it), and keeps its extra pages out of the display cache.
Drop the now-internal buildConversationQueryParams export (knip), replace the try/finally blocks the React Compiler rejects with explicit cleanup on both paths (oxlint), and give the export trigger an aria-label so the spread Button props type-check.
…ton-no-longer-works-for-large
Leaving and returning to the transcript tab remounts the export button with a reset isExporting, so a second export could start and collide with the first on the shared query key, cancelling both. Cancel and clear any in-flight export before starting, leave the shared entry for the next export (or gc) instead of removing it in cleanup, and swallow the resulting CancelledError on the superseded export.
A plain scraps Button no longer accepts the DropdownMenu trigger props; use OverlayTrigger.Button, the trigger variant the other dropdowns use.
Drop the isFullyLoaded/loadedNodes/title props: the export observer is seeded from the display cache, so it already knows whether a fetch is needed and reads the title from the pages. The button only needs the conversation.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 62c6f34. Configure here.
Reading the display cache synchronously lets a fully-cached export copy or download within the click instead of awaiting cancelQueries first, which would drop the user activation Safari needs. Only the fetch path (cache incomplete) awaits and shows the spinner.
The transcript Copy button broke for large conversations once the detail view stopped loading every span up front, so it silently returned a partial transcript. This replaces it with an Export transcript dropdown that copies the transcript to the clipboard or downloads it as a Markdown file. The transcript always covers the whole conversation: when the detail view already has every page cached it is reused instantly, otherwise every page is fetched on demand behind a loading state. The export fetch is independent of the display query, so exporting a large conversation does not bloat the transcript or timeline view. Closes TET-3103

The transcript Copy button broke for large conversations once the detail view stopped loading every span up front, so it silently returned a partial transcript.
This replaces it with an Export transcript dropdown that copies the transcript to the clipboard or downloads it as a Markdown file. The transcript always covers the whole conversation: when the detail view already has every page cached it is reused instantly, otherwise every page is fetched on demand behind a loading state. The export fetch is independent of the display query, so exporting a large conversation does not bloat the transcript or timeline view.
Closes TET-3103