Skip to content

feat(webui): Add export-all and per-row copy buttons to search results table (resolves #1980).#2185

Merged
junhaoliao merged 13 commits into
y-scope:mainfrom
goynam:feat/webui-copy-results-button
Apr 17, 2026
Merged

feat(webui): Add export-all and per-row copy buttons to search results table (resolves #1980).#2185
junhaoliao merged 13 commits into
y-scope:mainfrom
goynam:feat/webui-copy-results-button

Conversation

@goynam

@goynam goynam commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

Description

Adds an Actions column to the search results virtual table with two capabilities:

  • Export all — An icon-only download button in the column header exports the full result set as a JSONL file (clp-search-results-<timestamp>.jsonl). Disabled until the query completes and results are available.

    Table header showing the export-all download button in the Actions column

    Export success toast notification after clicking the export-all button

  • Copy event — A per-row copy icon button copies the individual log event (formatted as a single JSONL line) to the clipboard. This avoids the memory concerns of a "copy all" approach by keeping each operation O(1).

    A data row showing the per-row copy button (copy icon) on the right side of the Actions column

    Hovering shows a tooltip:

    Copy button with "Copy this event" tooltip

The JSONL format uses ISO 8601 timestamps — intentionally chosen over the human-readable display format because ISO 8601 is more immediately recognizable to both humans and automated parsers, and is the de facto standard for structured log data interchange.

Overall layout:

Full search results table with the Actions column on the right

Key design decisions

  • Column-based placement — Export and copy buttons live inside the table as a new rightmost column, avoiding the need for a separate container above the table (which would require adjusting tableHeight).
  • Store side-channel — The column header component (ActionsHeader) needs to trigger an export, but only the parent SearchResultsVirtualTable has access to the searchResults cursor data. The export callback is stored in a closure variable inside the zustand create() callback (not zustand state), so registering it doesn't trigger re-renders.
  • Deferred blob URL revocation — Revoking the blob URL immediately after anchor.click() silently aborts downloads on Firefox. Revocation is deferred by 60 seconds.

Checklist

  • The PR satisfies the [contribution guidelines][yscope-contrib-guidelines].
  • This is a breaking change and that has been indicated in the PR title, OR this isn't a breaking change.
  • Necessary docs have been updated, OR no docs need to be updated.

Validation performed

  • All webui lints pass (npm run lint:check --workspaces with --max-warnings 0).
  • TypeScript compiles cleanly (tsc --noEmit).
  • Manual testing: searched with * query, verified export-all downloads a JSONL file with correct content, verified per-row copy copies a single JSONL line to clipboard, verified button enable/disable states.

Summary by CodeRabbit

Release Notes

New Features

  • Added ability to export all search results as a JSONL file with timestamped filename.
  • Added copy-to-clipboard functionality for individual search results.
  • Export button appears in the search results table header and is only enabled when results are available.
  • User receives success or error feedback when exporting or copying results.

@goynam goynam requested a review from a team as a code owner April 7, 2026 19:53
@coderabbitai

coderabbitai Bot commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This pull request introduces export functionality for search results. It adds an export handler registration mechanism to the search store, creates a new ActionsHeader component with an export button, provides JSONL formatting utilities for row-level operations, implements a browser-based file download utility, and extends the state management to support configurable export callbacks.

Changes

Cohort / File(s) Summary
Core Export UI Components
SearchResultsVirtualTable/index.tsx, SearchResultsVirtualTable/ActionsHeader.tsx
Added export handler registration and memoized handleExport function to SearchResultsVirtualTable; introduced new ActionsHeader component with export button that reads store state and triggers export with disabled state logic based on search completion and result count.
State Management
SearchState/index.tsx
Extended SearchState store with onSearchResultsExport callback and setOnSearchResultsExport setter, using closure-scoped implementation pattern for handler registration and invocation.
Utilities & Formatting
SearchResultsVirtualTable/typings.tsx, Native/utils.ts, download.ts
Added JSONL serialization helpers (formatResultAsJsonl, formatTimestamp), timestamp conversion utilities (getExportEventTimestamp, getExportFilenameTimestamp), per-row copy-to-clipboard button with Actions column, and cross-browser file download function with URL revocation scheduling.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant ActionsHeader
    participant SearchState as SearchState<br/>(Store)
    participant SearchResultsVirtualTable
    participant Download as Download<br/>Utility
    participant Browser

    User->>ActionsHeader: Click export button
    ActionsHeader->>SearchState: onSearchResultsExport()
    SearchState->>SearchResultsVirtualTable: Invoke registered handler
    SearchResultsVirtualTable->>SearchResultsVirtualTable: formatResultsAsJSONL()
    SearchResultsVirtualTable->>Download: downloadTextFile(lines, filename)
    Download->>Download: Create Blob & Object URL
    Download->>Browser: Trigger anchor click for download
    Browser->>User: Download JSONL file
    Download->>Download: Schedule URL revocation (60s delay)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title accurately describes the main changes: adding export-all functionality and per-row copy buttons to the search results table, matching the changeset across multiple components.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/SearchResultsVirtualTable/index.tsx`:
- Line 53: In SearchResultsVirtualTable update the conditional that currently
uses the JavaScript negation on the searchResults variable: replace the
`!searchResults` usage with the repo-preferred boolean style by checking
equality against false (i.e., use false == searchResults) and keep the existing
length check (0 === searchResults.length) so the overall condition remains
equivalent; modify the if condition that references searchResults accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 64a8ee11-dcb6-47e7-99ed-b812594e3dd4

📥 Commits

Reviewing files that changed from the base of the PR and between ad31b89 and dffcc2dd9e0953c40e81809945ca1c3a130b0500.

📒 Files selected for processing (1)
  • components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/SearchResultsVirtualTable/index.tsx

@goynam goynam force-pushed the feat/webui-copy-results-button branch from dffcc2d to 98aac00 Compare April 7, 2026 19:59

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/SearchResultsVirtualTable/index.tsx`:
- Around line 56-89: The click handler and handleCopyAll currently allow
unhandled rejections and don't handle unavailable clipboard API; update
handleCopyAll to check navigator.clipboard exists, wrap the writeText call in
try/catch, and call messageApi.error(...) on failure (include a helpful
message), and remove the .catch that rethrows in the Button onClick (or simply
call handleCopyAll() without rethrowing). Refer to handleCopyAll and the Button
onClick/tooltip block to implement these changes so failures are surfaced to
users instead of propagating as unhandled promise rejections.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 514c6e9f-8400-47b0-b5a2-f7b191ba4c49

📥 Commits

Reviewing files that changed from the base of the PR and between dffcc2dd9e0953c40e81809945ca1c3a130b0500 and 98aac007dfe9e26a66848eff4aaed900834aeffe.

📒 Files selected for processing (2)
  • components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/SearchResultsVirtualTable/index.module.css
  • components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/SearchResultsVirtualTable/index.tsx

@goynam goynam force-pushed the feat/webui-copy-results-button branch from 98aac00 to 3f6f169 Compare April 7, 2026 20:09

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/SearchResultsVirtualTable/index.tsx`:
- Line 91: In SearchResultsVirtualTable replace the redundant CSS access
styles["copyButtonContainer"] || "" with the direct property access
styles.copyButtonContainer to simplify and preserve type-safety; locate the div
in the SearchResultsVirtualTable component (the element using className on the
copyButtonContainer) and update its className accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: c8ddd0c8-f97c-43fa-98ac-71812c652955

📥 Commits

Reviewing files that changed from the base of the PR and between 98aac007dfe9e26a66848eff4aaed900834aeffe and 3f6f169c3dfcd53ab4a486e2f4238e203191f53b.

📒 Files selected for processing (2)
  • components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/SearchResultsVirtualTable/index.module.css
  • components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/SearchResultsVirtualTable/index.tsx

@junhaoliao

Copy link
Copy Markdown
Member

Thanks for the contribution - this seems useful addition

One concern I have is that the current approach merges all results into a single string before copying to the clipboard. If there are a large number of results, this could put significant pressure on browser memory. Also, Chromium's V8 engine has a string size limit (~536.8M chars. see https://chromium.googlesource.com/v8/v8/+/ef40f0628efc7e9c15b6f28edd69aa76480f064d/src/objects/string.h#531), which we might run into in extreme cases.

Would you be open to exploring a slightly different approach by splitting this into two features?

  1. Instead of a "Copy all" button, perhaps we could add an "Export all" option that streams the log events into a Blob and triggers a download. This would avoid holding everything in memory at once.
  2. For copying, we could add a per-log "Copy" button that appears on hover. This keeps the UX clean while still making it easy to copy individual entries.

Happy to hear your thoughts

@junhaoliao junhaoliao self-requested a review April 7, 2026 20:41
@junhaoliao junhaoliao self-assigned this Apr 7, 2026
@junhaoliao junhaoliao added this to the Mid-April 2026 milestone Apr 7, 2026
Adds an 'Export All' button that streams results into a Blob and
triggers a file download, avoiding large string concatenation in
memory. The button only appears when results are present.
@goynam goynam force-pushed the feat/webui-copy-results-button branch from 3f6f169 to 3f66363 Compare April 8, 2026 05:21
@goynam

goynam commented Apr 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the contribution - this seems useful addition

One concern I have is that the current approach merges all results into a single string before copying to the clipboard. If there are a large number of results, this could put significant pressure on browser memory. Also, Chromium's V8 engine has a string size limit (~536.8M chars. see https://chromium.googlesource.com/v8/v8/+/ef40f0628efc7e9c15b6f28edd69aa76480f064d/src/objects/string.h#531), which we might run into in extreme cases.

Would you be open to exploring a slightly different approach by splitting this into two features?

  1. Instead of a "Copy all" button, perhaps we could add an "Export all" option that streams the log events into a Blob and triggers a download. This would avoid holding everything in memory at once.
  2. For copying, we could add a per-log "Copy" button that appears on hover. This keeps the UX clean while still making it easy to copy individual entries.

Happy to hear your thoughts

thanks for the feedback. updated with export all button. Skipped per log "Copy" as think anyways if someone clicks on a log row, it selects the same and normal copy action would work.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/SearchResultsVirtualTable/index.tsx (1)

96-96: 🧹 Nitpick | 🔵 Trivial

Simplify CSS module access for clarity.

styles["exportButtonContainer"] || "" is redundant; styles.exportButtonContainer is cleaner and maintains type-safety.

Suggested cleanup
-                <div className={styles["exportButtonContainer"] || ""}>
+                <div className={styles.exportButtonContainer}>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/SearchResultsVirtualTable/index.tsx`
at line 96, Replace the redundant CSS-module access in the JSX where the div
uses styles["exportButtonContainer"] || "" with the simpler, type-safe
dot-access styles.exportButtonContainer; update the element in
SearchResultsVirtualTable (index.tsx) to use styles.exportButtonContainer so the
code is clearer and removes the unnecessary fallback string.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/SearchResultsVirtualTable/index.tsx`:
- Around line 108-113: The tableHeight is passed directly to
VirtualTable.scroll.y and doesn't account for the export button container
(~40px) when hasResults is true; update the code that computes/passes
tableHeight (used with VirtualTable and symbol names like VirtualTable,
tableHeight, hasResults, and searchResultsTableColumns) to subtract 40 (or
Math.max(0, tableHeight - 40)) when hasResults is true, assign it to a new local
name such as adjustedTableHeight, and use adjustedTableHeight for the scroll.y
prop to prevent overflow.

---

Duplicate comments:
In
`@components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/SearchResultsVirtualTable/index.tsx`:
- Line 96: Replace the redundant CSS-module access in the JSX where the div uses
styles["exportButtonContainer"] || "" with the simpler, type-safe dot-access
styles.exportButtonContainer; update the element in SearchResultsVirtualTable
(index.tsx) to use styles.exportButtonContainer so the code is clearer and
removes the unnecessary fallback string.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4120fd27-d3ab-4214-8d6b-0108026c1147

📥 Commits

Reviewing files that changed from the base of the PR and between 3f6f169c3dfcd53ab4a486e2f4238e203191f53b and 3f66363.

📒 Files selected for processing (2)
  • components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/SearchResultsVirtualTable/index.module.css
  • components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/SearchResultsVirtualTable/index.tsx

Comment on lines +108 to +113
<VirtualTable<SearchResult>
columns={searchResultsTableColumns}
dataSource={searchResults || []}
pagination={false}
rowKey={(record) => record._id}
scroll={{y: tableHeight, x: "max-content"}}/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Adjust tableHeight to account for the export button container.

When hasResults is true, the export button container consumes approximately 40px (button height + margin). The tableHeight is passed directly to VirtualTable.scroll.y without adjustment, which may cause the table to overflow its parent container.

Proposed fix
+    // Height of export button container (button ~32px + margin-bottom 8px)
+    const EXPORT_BUTTON_CONTAINER_HEIGHT = 40;
+    const adjustedTableHeight = hasResults
+        ? tableHeight - EXPORT_BUTTON_CONTAINER_HEIGHT
+        : tableHeight;
+
     return (
         <div>
             {contextHolder}
             {hasResults && (
                 <div className={styles.exportButtonContainer}>
                     <Tooltip title={"Export all results as a text file"}>
                         <Button
                             icon={<DownloadOutlined/>}
                             size={"small"}
                             onClick={handleExportAll}
                         >
                             {"Export All"}
                         </Button>
                     </Tooltip>
                 </div>
             )}
             <VirtualTable<SearchResult>
                 columns={searchResultsTableColumns}
                 dataSource={searchResults || []}
                 pagination={false}
                 rowKey={(record) => record._id}
-                scroll={{y: tableHeight, x: "max-content"}}/>
+                scroll={{y: adjustedTableHeight, x: "max-content"}}/>
         </div>
     );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/SearchResultsVirtualTable/index.tsx`
around lines 108 - 113, The tableHeight is passed directly to
VirtualTable.scroll.y and doesn't account for the export button container
(~40px) when hasResults is true; update the code that computes/passes
tableHeight (used with VirtualTable and symbol names like VirtualTable,
tableHeight, hasResults, and searchResultsTableColumns) to subtract 40 (or
Math.max(0, tableHeight - 40)) when hasResults is true, assign it to a new local
name such as adjustedTableHeight, and use adjustedTableHeight for the scroll.y
prop to prevent overflow.

@junhaoliao junhaoliao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on my earlier review - thanks for addressing the clipboard load handling! After thinking about this more, I'd like to suggest a direction that I think would work even better for the overall UX. Happy to hear your thoughts.

Suggested approach

Instead of placing the export button above the table in a separate container, I think it would be cleaner to integrate the export functionality directly into the table as a new rightmost column. This avoids the tableHeight adjustment concern that CodeRabbit flagged and keeps the UI compact.

Here's what I'm thinking:

Here's how the overall layout looks:

Full search results table with the Actions column on the right

Export-all button in the column header

A small icon-only button (DownloadOutlined, size="small", type="text") in the header of a new "Actions" column on the right side of the table. The button is disabled when the query hasn't completed or there are zero results.

Table header showing the export-all download button in the Actions column

Clicking it downloads a JSONL file and shows a success toast:

Export success toast notification after clicking the export-all button

Per-row "Copy this event" button

Instead of "Copy all" (which has the memory concerns I raised earlier), a per-row copy button in the same Actions column cell. Clicking it copies that single log event as a JSONL line to the clipboard. This keeps each operation O(1) in memory.

A data row showing the per-row copy button (copy icon) on the right side of the Actions column

Hovering over the copy button shows a tooltip:

Copy button with "Copy this event" tooltip

Export format: JSONL

Rather than plain text, export as JSONL ({"timestamp": "...", "message": ...}\n...). The timestamp is formatted as an ISO 8601 string — we intentionally use ISO 8601 rather than the human-readable format used in the table display column because it's more immediately recognizable to both humans and automated parsers, and it's the de facto standard for structured log data interchange. The message field is included as a parsed JSON object if it's valid JSON, otherwise as a raw string. The filename would be clp-search-results-<timestamp>.jsonl.


Implementation details

1. New file: components/webui/client/src/utils/download.ts

Shared download utilities. Three exported functions:

import dayjs from "dayjs";


// Defer revocation by this amount because revoking the blob URL immediately after `click()`
// silently aborts downloads on some browsers (e.g., Firefox).
const REVOCATION_DELAY_MS = 60_000;

/**
 * Returns a filesystem-safe timestamp string suitable for export filenames.
 *
 * @return ISO-8601-like string with colons and dots replaced by dashes.
 */
const getExportFilenameTimestamp = (): string => (
    dayjs().format("YYYY-MM-DDTHH-mm-ss-SSS[Z]")
);

/**
 * Formats a numeric timestamp of an event as an ISO 8601 string.
 *
 * @param timestamp
 * @return ISO 8601 string.
 */
const getExportEventTimestamp = (timestamp: number): string => (
    dayjs(timestamp).toISOString()
);

/**
 * Triggers a plain-text file download in the browser by creating a temporary
 * Blob URL and clicking a hidden anchor element.
 *
 * @param lines Iterable of strings to write into the file.
 * @param filename Name for the downloaded file.
 */
const downloadTextFile = (lines: Iterable<string>, filename: string): void => {
    const blob = new Blob([...lines], {type: "text/plain"});
    const url = URL.createObjectURL(blob);
    const anchor = document.createElement("a");
    anchor.href = url;
    anchor.download = filename;
    anchor.click();
    setTimeout(() => {
        URL.revokeObjectURL(url);
    }, REVOCATION_DELAY_MS);
};

export {
    downloadTextFile,
    getExportEventTimestamp,
    getExportFilenameTimestamp,
};
  • getExportFilenameTimestamp() — Produces a filesystem-safe timestamp for filenames (colons and dots replaced with dashes).
  • getExportEventTimestamp(timestamp) — Formats a numeric event timestamp as ISO 8601. We intentionally use ISO 8601 rather than the human-readable display format because it's more recognizable to both humans and automated parsers, and it's the de facto standard for structured log data interchange.
  • downloadTextFile(lines, filename) — Creates a Blob from the lines, creates a temporary object URL, clicks a hidden <a> to trigger the download. Revokes the URL after 60 seconds (revoking immediately after click() silently aborts downloads on Firefox).

2. New file: components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/SearchResultsVirtualTable/ActionsHeader.tsx

A React component that renders the export-all button inside the Actions column header. It has no props — instead, it reads from the zustand search store. This is separate from typings.tsx to keep the column definitions file focused on types and column config.

import {DownloadOutlined} from "@ant-design/icons";
import {
    Button,
    Tooltip,
} from "antd";

import useSearchStore from "../../../../SearchState/index";
import {SEARCH_UI_STATE} from "../../../../SearchState/typings";


/**
 * Column header component with an export-all button.
 *
 * @return
 */
const ActionsHeader = () => {
    const onSearchResultsExport = useSearchStore((state) => state.onSearchResultsExport);
    const numResults = useSearchStore((state) => state.numSearchResultsTable);
    const searchUiState = useSearchStore((state) => state.searchUiState);

    return (
        <Tooltip title={"Export all results as JSONL"}>
            <Button
                disabled={SEARCH_UI_STATE.DONE !== searchUiState || 0 === numResults}
                icon={<DownloadOutlined/>}
                size={"small"}
                type={"text"}
                onClick={onSearchResultsExport}/>
        </Tooltip>
    );
};

export default ActionsHeader;

The button reads numSearchResultsTable and searchUiState from the store. It's disabled when the query hasn't completed (searchUiState !== SEARCH_UI_STATE.DONE) or there are zero results. On click it calls onSearchResultsExport(), which is the callback registered by the parent table component via the store side-channel (see step 3).

3. Modified: components/webui/client/src/pages/SearchPage/SearchState/index.tsx

Add two new members to the SearchState interface:

/**
 * Triggers a file download of the current search results. The
 * implementation is registered by the component that owns the cursor
 * subscription via setOnSearchResultsExport.
 */
onSearchResultsExport: () => void;
setOnSearchResultsExport: (fn: (() => void) | null) => void;

Important: the implementation is stored in a local closure variable inside the create() callback, not in zustand state. This means setOnSearchResultsExport does not trigger re-renders across all consumers. Change the store creation from the current flat style to one that has a closure:

const useSearchStore = create<SearchState>((set) => {
    let onSearchResultsExportImpl: (() => void) | null = null;

    return {
        ...SEARCH_STATE_DEFAULT,
        onSearchResultsExport: () => {
            onSearchResultsExportImpl?.();
        },
        setOnSearchResultsExport: (fn) => {
            onSearchResultsExportImpl = fn;
        },
        // ... rest of the existing actions unchanged ...
    };
});

Also make sure numSearchResultsTable is in SEARCH_STATE_DEFAULT (it's read by ActionsHeader).

4. Modified: components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/SearchResultsVirtualTable/typings.tsx

Several additions to this file. The existing SearchResult interface and column definitions stay, with the following additions and adjustments:

Extract formatTimestamp as a named function (was previously an inline arrow in the timestamp column's render prop):

/**
 * Formats a numeric timestamp as a UTC datetime string.
 *
 * @param timestamp
 * @return The formatted datetime string.
 */
const formatTimestamp = (timestamp: number): string => (
    dayjs.utc(timestamp).format(DATETIME_FORMAT_TEMPLATE)
);

Add formatResultAsJsonl — serializes a search result as a single JSON line (no trailing newline). The timestamp uses getExportEventTimestamp() to produce an ISO 8601 string. For the message field: tries JSON.parse — if it succeeds, includes the parsed object; if it throws, includes the raw string.

/**
 * Serializes a search result as a JSONL line. If the message field is valid
 * JSON it is included as a parsed object; otherwise it is kept as a string.
 *
 * @param result
 * @return A single JSON line (without trailing newline).
 */
const formatResultAsJsonl = (result: SearchResult): string => {
    let messageValue: unknown;
    try {
        messageValue = JSON.parse(result.message);
    } catch {
        messageValue = result.message;
    }

    return JSON.stringify({
        timestamp: getExportEventTimestamp(result.timestamp),
        message: messageValue,
    });
};

Add renderActionsCell — a column render function for the Actions column. This is intentionally a plain function (not a React component) to satisfy the react/destructuring-assignment lint rule.

/**
 * Cell renderer for the Actions column. Renders a per-row copy button.
 *
 * @param _
 * @param record
 * @return
 */
const renderActionsCell = (_: unknown, record: SearchResult) => {
    const handleCopyRow = () => {
        navigator.clipboard.writeText(formatResultAsJsonl(record))
            .catch((e: unknown) => {
                console.error("Failed to copy search result to clipboard", e);
            });
    };

    return (
        <Tooltip title={"Copy this event"}>
            <Button
                icon={<CopyOutlined/>}
                size={"small"}
                type={"text"}
                onClick={handleCopyRow}/>
        </Tooltip>
    );
};

Add an Actions column at the end of searchResultsTableColumns:

{
    align: "right",
    key: "actions",
    render: renderActionsCell,
    title: <ActionsHeader/>,
    width: 6,
},

Adjust column widths — Timestamp: 15→12, Message: 85→82 to make room for the 6-width Actions column.

New exports — add formatResultAsJsonl and formatTimestamp alongside the existing searchResultsTableColumns.

New imports — add CopyOutlined from @ant-design/icons, Button and Tooltip from antd, getExportEventTimestamp from utils/download, and ActionsHeader from ./ActionsHeader.

5. Modified: components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/SearchResultsVirtualTable/index.tsx

New imports: add useCallback from react; message from antd; downloadTextFile and getExportFilenameTimestamp from utils/download; formatResultAsJsonl from ./typings; destructure setOnSearchResultsExport from the store.

Add message.useMessage() — provides messageApi and contextHolder for success/error toast notifications.

Add handleExport callback wrapped in useCallback (depends on messageApi and searchResults):

/**
 * Exports all search results as a JSONL file download.
 *
 * NOTE: Results are exported in the original cursor order (i.e., timestamp descending),
 * which may differ from the user's current table sort.
 */
const handleExport = useCallback(() => {
    if (null === searchResults || 0 === searchResults.length) {
        return;
    }

    try {
        downloadTextFile(
            searchResults.map((r) => `${formatResultAsJsonl(r)}\n`),
            `clp-search-results-${getExportFilenameTimestamp()}.jsonl`
        );
        messageApi.success(`Exported ${searchResults.length} results`);
    } catch (e) {
        messageApi.error("Failed to export results");
        console.error(e);
    }
}, [
    messageApi,
    searchResults,
]);

Register the handler in a useEffect with cleanup:

useEffect(() => {
    setOnSearchResultsExport(handleExport);

    return () => {
        setOnSearchResultsExport(null);
    };
}, [
    handleExport,
    setOnSearchResultsExport,
]);

Wrap the return value in a fragment (<>) with {contextHolder} before the VirtualTable.

Remove x: "max-content" from scroll — AntD's virtual table requires scroll.x to be a number; the string value causes a console warning. Just use scroll={{y: tableHeight}}.

What to remove from your PR

  • index.module.css — the separate container for the export button is no longer needed
  • The formatResultLine function — replaced by formatResultAsJsonl
  • The hasResults conditional and the button container div above the table

Notes

  • Why the store side-channel? The column header (ActionsHeader) needs to trigger an export, but only the parent SearchResultsVirtualTable has access to the searchResults cursor data. Storing the callback in a closure variable inside the zustand create() callback (rather than as zustand state) lets us bridge the two components without triggering re-renders on every results update.
  • Blob URL revocation: revoking immediately after anchor.click() silently aborts downloads on Firefox. Deferring by 60 seconds avoids that.
  • scroll.x: "max-content" removed: AntD's virtual table requires scroll.x to be a number.

After we are done with the changes, let's also fix the lint issues as reported by the CI before we proceed to review this again.

If you're short on time and OK with the above direction, I'm also happy to push these changes directly to your branch — just let me know.

@junhaoliao junhaoliao requested a review from hoophalab April 15, 2026 20:48
@junhaoliao junhaoliao changed the title feat(webui): add copy-all button to search results table feat(webui): Add export-all and per-row copy buttons to search results table (resolves #2185). Apr 15, 2026
@junhaoliao junhaoliao changed the title feat(webui): Add export-all and per-row copy buttons to search results table (resolves #2185). feat(webui): Add export-all and per-row copy buttons to search results table (resolves #1980). Apr 15, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/SearchResultsVirtualTable/typings.tsx`:
- Around line 73-90: renderActionsCell's handleCopyRow should check
navigator.clipboard availability and surface user feedback on success or failure
instead of only logging to console; update handleCopyRow (in renderActionsCell)
to first if (!navigator?.clipboard) show antd message.error like "Clipboard not
available", otherwise call
navigator.clipboard.writeText(formatResultAsJsonl(record)) and in .then show
message.success("Copied"), in .catch show message.error("Failed to copy: " +
error message); use the existing formatResultAsJsonl helper and import/use antd
message (or message.useMessage if you need consistent positioning) to display
the notifications.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5b828e0c-fe9f-4a02-aee4-f3e9936fc1ff

📥 Commits

Reviewing files that changed from the base of the PR and between 3f66363 and 2548dba.

📒 Files selected for processing (6)
  • components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/SearchResultsVirtualTable/ActionsHeader.tsx
  • components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/SearchResultsVirtualTable/index.tsx
  • components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/SearchResultsVirtualTable/typings.tsx
  • components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/utils.ts
  • components/webui/client/src/pages/SearchPage/SearchState/index.tsx
  • components/webui/client/src/utils/download.ts

Comment on lines +73 to +90
const renderActionsCell = (_: unknown, record: SearchResult) => {
const handleCopyRow = () => {
navigator.clipboard.writeText(formatResultAsJsonl(record))
.catch((e: unknown) => {
console.error("Failed to copy search result to clipboard", e);
});
};

return (
<Tooltip title={"Copy this event"}>
<Button
icon={<CopyOutlined/>}
size={"small"}
type={"text"}
onClick={handleCopyRow}/>
</Tooltip>
);
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add clipboard availability check and user feedback for copy failures.

The per-row copy button doesn't verify navigator.clipboard availability and provides no user feedback on failure—users won't know if the copy failed. This contrasts with the bulk export handler in index.tsx which properly shows error messages.

Proposed fix
+import {message} from "antd";
+
 const renderActionsCell = (_: unknown, record: SearchResult) => {
     const handleCopyRow = () => {
+        if (null == navigator.clipboard || "function" !== typeof navigator.clipboard.writeText) {
+            message.error("Clipboard is unavailable");
+            return;
+        }
+
         navigator.clipboard.writeText(formatResultAsJsonl(record))
-            .catch((e: unknown) => {
-                console.error("Failed to copy search result to clipboard", e);
-            });
+            .then(() => {
+                message.success("Copied to clipboard");
+            })
+            .catch((e: unknown) => {
+                console.error("Failed to copy search result to clipboard", e);
+                message.error("Failed to copy to clipboard");
+            });
     };

Note: Using message directly from antd import works for quick feedback but doesn't provide the same contextual placement as message.useMessage(). If consistent positioning is important, consider lifting the message API to a shared context.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@components/webui/client/src/pages/SearchPage/SearchResults/SearchResultsTable/Native/SearchResultsVirtualTable/typings.tsx`
around lines 73 - 90, renderActionsCell's handleCopyRow should check
navigator.clipboard availability and surface user feedback on success or failure
instead of only logging to console; update handleCopyRow (in renderActionsCell)
to first if (!navigator?.clipboard) show antd message.error like "Clipboard not
available", otherwise call
navigator.clipboard.writeText(formatResultAsJsonl(record)) and in .then show
message.success("Copied"), in .catch show message.error("Failed to copy: " +
error message); use the existing formatResultAsJsonl helper and import/use antd
message (or message.useMessage if you need consistent positioning) to display
the notifications.

@hoophalab hoophalab left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Some comments

* @param result
* @return A single JSON line (without trailing newline).
*/
const formatResultAsJsonl = (result: SearchResult): string => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are these functions put in typings.tsx? Shouldn't them be in utils.tsx?

}, [
handleExport,
setOnSearchResultsExport,
]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We usually just put data shared by two components in SearchState. Is it better to just put searchResults in SearchState, and make onSearchResultsExport statically do handleExport? The performance penalty shouldn't be too big since zustand only checks the array.

@junhaoliao junhaoliao requested a review from hoophalab April 16, 2026 19:24

@hoophalab hoophalab left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@junhaoliao junhaoliao merged commit f6d39da into y-scope:main Apr 17, 2026
21 checks passed
junhaoliao added a commit to junhaoliao/clp that referenced this pull request May 17, 2026
…s table (resolves y-scope#1980). (y-scope#2185)

Co-authored-by: Naman Goyal <namangg@amazon.com>
Co-authored-by: Junhao Liao <junhao.liao@yscope.com>
Co-authored-by: hoophalab <200652805+hoophalab@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants