Skip to content

fix: redesign global search with cursor feeds#1945

Merged
yottahmd merged 6 commits intomainfrom
search-ui-cursor
Apr 2, 2026
Merged

fix: redesign global search with cursor feeds#1945
yottahmd merged 6 commits intomainfrom
search-ui-cursor

Conversation

@yottahmd
Copy link
Copy Markdown
Collaborator

@yottahmd yottahmd commented Apr 1, 2026

Summary

  • replace the unbounded global search page flow with dedicated DAG and doc cursor feeds plus cursor-based snippet expansion endpoints
  • update the search UI to use DAGs and Docs scope selection, infinite loading, lightweight previews, lazy match expansion, and no background polling
  • harden docs availability and broken search-root handling, simplify the new cursor/search code paths, and add focused search API and UI coverage

Testing

  • make api
  • pnpm gen:api
  • make fmt
  • go test ./internal/core/exec ./internal/persis/filedag ./internal/persis/filedoc ./internal/service/frontend/api/v1 -count=1
  • pnpm typecheck
  • pnpm test -- --run ui/src/pages/search/__tests__/index.test.tsx ui/src/features/search/components/__tests__/SearchResult.test.tsx
  • pnpm build

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 1, 2026

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8717fadc-fbea-4c2a-9523-534efad4c7c0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Introduces cursor-based global search functionality for DAGs and documents across the stack, including new REST API endpoints with pagination, core cursor encoding/decoding utilities, windowed grep matching, storage implementations with search methods, and a refactored UI with infinite-scroll feeds and scope-based filtering.

Changes

Cohort / File(s) Summary
API OpenAPI Schemas
api/v1/api.yaml, ui/src/api/v1/schema.ts
Added four new cursor-based search endpoints (GET /search/dags, GET /search/docs, GET /search/dags/{fileName}/matches, GET /search/docs/matches) with pagination parameters (SearchCursor, SearchLimit, SearchMatchLimit) and response schemas including DAGSearchFeedResponse, DocSearchFeedResponse, and SearchMatchesResponse.
Core Search Infrastructure
internal/core/exec/search_cursor.go, internal/core/exec/search_cursor_test.go
Added cursor serialization utilities (EncodeSearchCursor, DecodeSearchCursor), generic CursorResult[T] container with pagination fields, and ErrInvalidCursor error type; includes round-trip and error case tests.
DAG Search Interfaces & Types
internal/core/exec/dag.go
Extended DAGStore interface with SearchCursor and SearchMatches methods; introduced SearchDAGsOptions, SearchDAGMatchesOptions, and SearchDAGResult types for cursor-paginated DAG search results.
Document Search Interfaces & Types
internal/agent/doc.go
Added SearchDocsOptions, SearchDocMatchesOptions option types and SearchCursor, SearchMatches methods to DocStore; updated DocSearchResult with per-item match pagination fields (HasMoreMatches, NextMatchesCursor).
Grep Windowing Support
internal/persis/filedag/grep/grep.go, internal/persis/filedag/grep/grep_test.go
Added Offset/Limit to GrepOptions; introduced WindowResult type and GrepWindow function for incremental match collection with pagination; refactored Grep to delegate to new GrepWithCount; includes windowing pagination test.
DAG Storage Search Implementation
internal/persis/filedag/store.go, internal/persis/filedag/store_test.go
Implemented SearchCursor (files with query matches + per-DAG match pagination) and SearchMatches (file-scoped match snippets) with cursor validation, case-insensitive regex patterns, and error handling; added error path test for non-directory base path.
Document Storage Search Implementation
internal/persis/filedoc/store.go, internal/persis/filedoc/store_test.go
Implemented SearchCursor (document-paginated results with per-doc match limits and continuations) and SearchMatches (doc-scoped snippet pagination via GrepWindow); includes cursor versioning and error handling tests.
Frontend Search API Handlers
internal/service/frontend/api/v1/search.go, internal/service/frontend/api/v1/search_test.go
Added four handler methods (SearchDAGFeed, SearchDocFeed, SearchDagMatches, SearchDocMatches) with query validation, cursor decoding, response mapping, and error handling; includes end-to-end pagination, error, and access-control tests.
Test Mock Implementations
internal/cmd/migrator_test.go, internal/cmn/telemetry/collector_test.go, internal/runtime/agent/dbclient_test.go, internal/service/frontend/api/v1/docs_test.go
Added mock implementations of SearchCursor and SearchMatches for test doubles; updated docs test import alias and implemented cursor-paginated mock behavior for both DAG and document stores.
UI Search Components & Pagination
ui/src/features/search/components/SearchResult.tsx, ui/src/features/search/components/__tests__/SearchResult.test.tsx
Refactored SearchResult to support interactive, paginated match viewers with per-item "Show more matches" buttons; added query prop requirement and on-demand snippet loading via new /search/*/matches endpoints; includes test for pagination interaction.
Search Page & Infinite Scroll
ui/src/pages/search/index.tsx, ui/src/pages/search/__tests__/index.test.tsx
Refactored search page to support scoped feeds (dags vs docs) via URL parameter and toggle; replaced two useQuery calls with separate useInfinite feeds for each scope; added infinite-scroll via useAutoLoadMore and error handling (including 403 unavailability for docs); includes tests for 403 and load-error scenarios.
Pagination UI Flexibility
ui/src/features/dags/components/common/DAGPagination.tsx
Made onPageLimitChange optional and added optional showPageLimitSelector flag to conditionally hide page-size selector UI.
Provider Enum Update
ui/src/pages/setup.tsx
Updated OpenAI Codex provider enum reference from openaiCodex to openai_codex in supported providers validation.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client / UI
    participant Handler as Search Handler
    participant Storage as DAG/Doc Storage
    participant Grep as Grep Engine

    Client->>Handler: GET /search/dags?q=...&cursor=...&limit=...
    activate Handler
    Handler->>Handler: Validate query parameter
    Handler->>Storage: SearchCursor(ctx, opts)
    activate Storage
    Storage->>Storage: Decode cursor (version, query, offset)
    Storage->>Storage: Walk directory, filter files
    loop For each file up to limit
        Storage->>Grep: GrepWindow(data, pattern, offset, limit)
        activate Grep
        Grep->>Grep: Scan content, track matches
        Grep-->>Storage: WindowResult{matches, hasMore, nextOffset}
        deactivate Grep
        Storage->>Storage: Accumulate results
    end
    Storage-->>Handler: CursorResult{items, hasMore, nextCursor}, errors
    deactivate Storage
    Handler->>Handler: Convert to API response
    Handler->>Handler: Encode nextCursor (page offset)
    Handler-->>Client: DAGSearchFeedResponse{items, hasMore, nextCursor}
    deactivate Handler
    
    Client->>Handler: GET /search/dags/{fileName}/matches?q=...&cursor=...
    activate Handler
    Handler->>Storage: SearchMatches(ctx, fileName, opts)
    activate Storage
    Storage->>Grep: GrepWindow(data, pattern, offset, limit)
    activate Grep
    Grep-->>Storage: WindowResult{matches, hasMore, nextOffset}
    deactivate Grep
    Storage-->>Handler: CursorResult{matches, hasMore, nextCursor}
    deactivate Storage
    Handler-->>Client: SearchMatchesResponse{matches, hasMore, nextCursor}
    deactivate Handler
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: harden cursor search feeds' clearly and specifically describes the main change: improving robustness of the cursor-based search feed functionality across the codebase.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch search-ui-cursor

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.

@yottahmd yottahmd changed the title fix: harden cursor search feeds fix: redesign global search with cursor feeds Apr 1, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 10

🧹 Nitpick comments (2)
api/v1/api.yaml (1)

8457-8459: Reuse the canonical identifier schemas here.

These values are what callers will round-trip into /search/dags/{fileName}/matches and /search/docs/matches?path=.... Keeping them as raw strings duplicates the constraints and descriptions that already live in DAGFileName and DocPath.

♻️ Suggested ref update
        fileName:
-         type: string
-         description: "DAG file name without extension"
+         $ref: "#/components/schemas/DAGFileName"
...
        id:
-         type: string
+         $ref: "#/components/schemas/DocPath"

Also applies to: 8500-8501

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

In `@api/v1/api.yaml` around lines 8457 - 8459, The schema for the fileName and
related string fields duplicates existing identifier schemas; replace the inline
string schema/description for fileName with a $ref to the canonical DAGFileName
schema (and similarly replace the other occurrences with a $ref to DocPath where
appropriate) so callers use the same validation/description; update the schema
node that currently defines fileName (and the corresponding field at the other
occurrence) to reference DAGFileName or DocPath instead of defining type: string
and duplicate description.
ui/src/api/v1/schema.ts (1)

3446-3496: Align lazy-match pagination fields across both payload shapes.

DAGSearchPageItem / DocSearchPageItem use hasMoreMatches and nextMatchesCursor, but SearchMatchesResponse switches to hasMore and nextCursor. That forces consumers to translate the load-more response before merging it back into item state. Since this file is generated, I'd normalize the names in the OpenAPI source and regenerate.

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

In `@ui/src/api/v1/schema.ts` around lines 3446 - 3496, SearchMatchesResponse uses
hasMore/nextCursor while DAGSearchPageItem and DocSearchPageItem use
hasMoreMatches/nextMatchesCursor; update the OpenAPI source so
SearchMatchesResponse (components.schemas.SearchMatchesResponse) uses
hasMoreMatches and nextMatchesCursor to match DAGSearchPageItem and
DocSearchPageItem, then regenerate the client to update ui/src/api/v1/schema.ts
(look for SearchMatchesResponse, DAGSearchPageItem, DocSearchPageItem in the
spec and ensure the pagination field names are identical).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@api/v1/api.yaml`:
- Around line 8510-8514: Rename the OpenAPI schema SearchDAGsMatchItem to a
neutral name SearchMatchItem and update all $ref usages that point to
"#/components/schemas/SearchDAGsMatchItem" (including the matches array items at
the occurrences you saw and the refs around lines 8559-8562) to
"#/components/schemas/SearchMatchItem"; ensure the schema block itself is
renamed to SearchMatchItem in the components/schemas section and run a quick
validation to confirm no remaining references to SearchDAGsMatchItem remain.

In `@internal/persis/filedoc/store.go`:
- Around line 618-622: The cursor pagination is unsafe because docSearchCursor
only stores the last emitted ID, but WalkDir traverses depth-first so IDs are
not monotonic; update the cursor and paging logic to record a stable traversal
key (e.g., the file relative path or a composite key like "path|id") instead of
only ID, and use that key to resume (or alternatively collect matching entries,
sort them by stable key such as filepath then ID, and paginate from that sorted
list). Specifically, extend docSearchCursor with a LastPath (or Key) field,
change the emit/resume checks in the search function that currently compare ID
to instead compare the stable key, and ensure any cursor versioning (Version) is
bumped/handled so old cursors fail gracefully.
- Around line 785-805: SearchMatches currently lets malformed IDs fall through
to docFilePath/os.ReadFile and can return filesystem errors instead of a proper
ErrInvalidDocID; add an upfront check for the doc id at the top of SearchMatches
(before calling decodeDocMatchCursor/docFilePath) and return
agent.ErrInvalidDocID for invalid IDs, using the existing doc-id validation
helper used by other store methods (e.g., validateDocID or agent.ValidateDocID)
so ID parsing/validation is centralized and consistent.

In `@internal/service/frontend/api/v1/docs_test.go`:
- Around line 220-277: The mock implementations of SearchCursor and
SearchMatches must mirror the real store: use
exec.EncodeSearchCursor/DecodeSearchCursor to decode incoming opts.Cursor and to
set NextCursor (not raw IDs or decimal offsets), and ensure SearchMatches first
finds the target document by id and returns an empty CursorResult (not
ErrDocNotFound) when the doc exists but has no matching hits; also guard against
negative offsets from strconv.Atoi by clamping to 0 and to len(matches) before
slicing. Update mockDocStore.SearchCursor and mockDocStore.SearchMatches to
decode/encode cursors with exec.DecodeSearchCursor/exec.EncodeSearchCursor,
lookup the document before paging, and clamp computed offsets to [0,len(results
or matches)].

In `@internal/service/frontend/api/v1/search.go`:
- Around line 83-91: The mapping sets both Name and FileName to item.FileName
because exec.SearchDAGResult currently lacks a separate display name; update
exec.SearchDAGResult to include a Name (or DisplayName) field and populate it
where SearchDAGResult values are constructed, then change toDAGSearchPageItem to
set Name: item.Name (or item.DisplayName) while keeping FileName: item.FileName
so api.DAGSearchPageItem reflects a proper display name distinct from the file
path; alternatively, if duplication was intentional, add a clarifying comment in
toDAGSearchPageItem explaining why Name mirrors FileName.

In `@ui/src/features/dags/components/common/DAGPagination.tsx`:
- Around line 23-25: DAGPagination currently renders the page-size selector even
when there is no onPageLimitChange handler, making the UI appear interactive
without effect; update the rendering conditions so the selector (and its
presets/custom entries) is only rendered/enabled when showPageLimitSelector is
true AND onPageLimitChange is provided—i.e., guard the selector render and any
interactive entries with a check like (showPageLimitSelector && typeof
onPageLimitChange === 'function') in the DAGPagination component so that all
places that render the page-size selector (including the blocks around the
presets/custom entries and any dropdown items) are hidden or disabled when the
handler is absent.

In `@ui/src/features/search/components/__tests__/SearchResult.test.tsx`:
- Around line 1-5: This test file (SearchResult.test.tsx) is missing the
required GPL v3 license header; fix it by adding the GPL v3 banner to the top of
the file (same style used across TS/TSX/JS sources) or simply run the repository
tooling: execute make addlicense in this directory to apply the correct header
automatically so the imports and symbols like SearchResult, render, screen,
userEvent remain unchanged.

In `@ui/src/features/search/components/SearchResult.tsx`:
- Around line 119-130: The header in the SearchResult component (the Link
wrapping the <h3> that renders title and kind) can overflow and push the matches
counter off-screen; update the Link container inside SearchResult to include a
min-w-0 utility and add whitespace-normal and break-words utilities to the
heading that renders title (the <h3> using title and kind) so long titles wrap
correctly and prevent layout overflow while preserving the existing
justify-between layout.

In `@ui/src/pages/search/index.tsx`:
- Around line 3-8: This TSX file is missing the required GPL v3 license header;
add the repository's standard GPL header at the very top of the file (before any
imports) by running the repo toolchain command `make addlicense` (preferred) or
by inserting the exact header used across the codebase, ensuring it appears
above the import block that contains symbols like SearchResult, useInfinite,
SearchIcon, useLocation/useSearchParams and ToggleButton/ToggleGroup so the file
complies with the project's licensing guidelines.
- Around line 426-477: The search controls currently use the last submitted
query (currentFilters.searchVal) and block empty submits; update the Input,
Button and ToggleButton handlers to use the live draft state (searchVal) and
allow empty submissions so users can clear searches: remove the trim-based guard
in the Input onKeyDown (call onSubmit(searchVal) on Enter unconditionally),
ensure the Button is not disabled based on searchVal.trim() (allow click to call
onSubmit(searchVal)), and change both ToggleButton onClick calls to pass
syncFilters({ searchVal: searchVal, scope: 'dags' }) / syncFilters({ searchVal:
searchVal, scope: 'docs' }) instead of currentFilters.searchVal; keep
buildSearchParams usage unchanged since it already supports empty queries.

---

Nitpick comments:
In `@api/v1/api.yaml`:
- Around line 8457-8459: The schema for the fileName and related string fields
duplicates existing identifier schemas; replace the inline string
schema/description for fileName with a $ref to the canonical DAGFileName schema
(and similarly replace the other occurrences with a $ref to DocPath where
appropriate) so callers use the same validation/description; update the schema
node that currently defines fileName (and the corresponding field at the other
occurrence) to reference DAGFileName or DocPath instead of defining type: string
and duplicate description.

In `@ui/src/api/v1/schema.ts`:
- Around line 3446-3496: SearchMatchesResponse uses hasMore/nextCursor while
DAGSearchPageItem and DocSearchPageItem use hasMoreMatches/nextMatchesCursor;
update the OpenAPI source so SearchMatchesResponse
(components.schemas.SearchMatchesResponse) uses hasMoreMatches and
nextMatchesCursor to match DAGSearchPageItem and DocSearchPageItem, then
regenerate the client to update ui/src/api/v1/schema.ts (look for
SearchMatchesResponse, DAGSearchPageItem, DocSearchPageItem in the spec and
ensure the pagination field names are identical).
🪄 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: CHILL

Plan: Pro

Run ID: ae710e19-1e0d-4436-a251-275e5ca4724f

📥 Commits

Reviewing files that changed from the base of the PR and between b6c214f and 06072e6.

📒 Files selected for processing (25)
  • api/v1/api.gen.go
  • api/v1/api.yaml
  • internal/agent/doc.go
  • internal/cmd/migrator_test.go
  • internal/cmn/telemetry/collector_test.go
  • internal/core/exec/dag.go
  • internal/core/exec/search_cursor.go
  • internal/core/exec/search_cursor_test.go
  • internal/persis/filedag/grep/grep.go
  • internal/persis/filedag/grep/grep_test.go
  • internal/persis/filedag/store.go
  • internal/persis/filedag/store_test.go
  • internal/persis/filedoc/store.go
  • internal/persis/filedoc/store_test.go
  • internal/runtime/agent/dbclient_test.go
  • internal/service/frontend/api/v1/docs_test.go
  • internal/service/frontend/api/v1/search.go
  • internal/service/frontend/api/v1/search_test.go
  • ui/src/api/v1/schema.ts
  • ui/src/features/dags/components/common/DAGPagination.tsx
  • ui/src/features/search/components/SearchResult.tsx
  • ui/src/features/search/components/__tests__/SearchResult.test.tsx
  • ui/src/pages/search/__tests__/index.test.tsx
  • ui/src/pages/search/index.tsx
  • ui/src/pages/setup.tsx

Comment thread api/v1/api.yaml Outdated
Comment on lines +8510 to +8514
matches:
type: array
description: "Preview snippets for the result"
items:
$ref: "#/components/schemas/SearchDAGsMatchItem"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Use a neutral match-item schema for the shared doc/DAG responses.

SearchDAGsMatchItem is now referenced by document search too, so the generated docs and client types will still describe document hits as matches “within a DAG definition.” A generic SearchMatchItem would keep this shared contract accurate.

🧭 Suggested rename
        matches:
          type: array
          description: "Preview snippets for the result"
          items:
-           $ref: "#/components/schemas/SearchDAGsMatchItem"
+           $ref: "#/components/schemas/SearchMatchItem"
...
        matches:
          type: array
          items:
-           $ref: "#/components/schemas/SearchDAGsMatchItem"
+           $ref: "#/components/schemas/SearchMatchItem"

Rename the existing SearchDAGsMatchItem schema block to SearchMatchItem and update the other refs at the same time.

Also applies to: 8559-8562

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

In `@api/v1/api.yaml` around lines 8510 - 8514, Rename the OpenAPI schema
SearchDAGsMatchItem to a neutral name SearchMatchItem and update all $ref usages
that point to "#/components/schemas/SearchDAGsMatchItem" (including the matches
array items at the occurrences you saw and the refs around lines 8559-8562) to
"#/components/schemas/SearchMatchItem"; ensure the schema block itself is
renamed to SearchMatchItem in the components/schemas section and run a quick
validation to confirm no remaining references to SearchDAGsMatchItem remain.

Comment on lines +618 to +622
type docSearchCursor struct {
Version int `json:"v"`
Query string `json:"q"`
ID string `json:"id,omitempty"`
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Cursor pagination can skip results for mixed file/directory prefixes.

This cursor only remembers the last emitted doc ID, but filepath.WalkDir resumes in depth-first directory order, not monotonically by doc ID. With both a.md and a/b.md, page 1 can end on a/b; page 2 then skips a because id <= cursor.ID, so that file is never returned. The cursor needs to track actual traversal position, or the search needs an explicit stable sort order before paging.

Also applies to: 713-748

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

In `@internal/persis/filedoc/store.go` around lines 618 - 622, The cursor
pagination is unsafe because docSearchCursor only stores the last emitted ID,
but WalkDir traverses depth-first so IDs are not monotonic; update the cursor
and paging logic to record a stable traversal key (e.g., the file relative path
or a composite key like "path|id") instead of only ID, and use that key to
resume (or alternatively collect matching entries, sort them by stable key such
as filepath then ID, and paginate from that sorted list). Specifically, extend
docSearchCursor with a LastPath (or Key) field, change the emit/resume checks in
the search function that currently compare ID to instead compare the stable key,
and ensure any cursor versioning (Version) is bumped/handled so old cursors fail
gracefully.

Comment on lines +785 to +805
func (s *Store) SearchMatches(_ context.Context, id string, opts agent.SearchDocMatchesOptions) (*exec.CursorResult[*exec.Match], error) {
if opts.Query == "" {
return &exec.CursorResult[*exec.Match]{Items: []*exec.Match{}}, nil
}

cursor, err := decodeDocMatchCursor(opts.Cursor, opts.Query, id)
if err != nil {
return nil, err
}

path, err := s.docFilePath(id)
if err != nil {
return nil, err
}
data, err := os.ReadFile(path) //nolint:gosec // validated path within baseDir
if err != nil {
if os.IsNotExist(err) {
return nil, agent.ErrDocNotFound
}
return nil, err
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Validate id before resolving the match path.

SearchMatches is the odd doc entry point out: the other store methods reject malformed IDs up front, but this one falls through to docFilePath/os.ReadFile and can return a path-traversal/internal filesystem error or ErrDocNotFound instead of ErrInvalidDocID.

Proposed fix
 func (s *Store) SearchMatches(_ context.Context, id string, opts agent.SearchDocMatchesOptions) (*exec.CursorResult[*exec.Match], error) {
+	if err := agent.ValidateDocID(id); err != nil {
+		return nil, err
+	}
 	if opts.Query == "" {
 		return &exec.CursorResult[*exec.Match]{Items: []*exec.Match{}}, nil
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/persis/filedoc/store.go` around lines 785 - 805, SearchMatches
currently lets malformed IDs fall through to docFilePath/os.ReadFile and can
return filesystem errors instead of a proper ErrInvalidDocID; add an upfront
check for the doc id at the top of SearchMatches (before calling
decodeDocMatchCursor/docFilePath) and return agent.ErrInvalidDocID for invalid
IDs, using the existing doc-id validation helper used by other store methods
(e.g., validateDocID or agent.ValidateDocID) so ID parsing/validation is
centralized and consistent.

Comment on lines +220 to +277
func (m *mockDocStore) SearchCursor(_ context.Context, opts agent.SearchDocsOptions) (*exec.CursorResult[agent.DocSearchResult], error) {
results, err := m.Search(context.Background(), opts.Query)
if err != nil {
return nil, err
}
limit := max(opts.Limit, 1)
offset := 0
if opts.Cursor != "" {
for i, item := range results {
if item.ID == opts.Cursor {
offset = i + 1
break
}
}
}
end := min(offset+limit, len(results))
pageItems := make([]agent.DocSearchResult, 0, end-offset)
for _, item := range results[offset:end] {
pageItems = append(pageItems, *item)
}
result := &exec.CursorResult[agent.DocSearchResult]{
Items: pageItems,
HasMore: end < len(results),
}
if result.HasMore && len(pageItems) > 0 {
result.NextCursor = pageItems[len(pageItems)-1].ID
}
return result, nil
}

func (m *mockDocStore) SearchMatches(_ context.Context, id string, opts agent.SearchDocMatchesOptions) (*exec.CursorResult[*exec.Match], error) {
results, err := m.Search(context.Background(), opts.Query)
if err != nil {
return nil, err
}
for _, result := range results {
if result.ID != id {
continue
}
limit := max(opts.Limit, 1)
offset := 0
if opts.Cursor != "" {
if n, err := strconv.Atoi(opts.Cursor); err == nil {
offset = min(len(result.Matches), n)
}
}
end := min(offset+limit, len(result.Matches))
cursorResult := &exec.CursorResult[*exec.Match]{
Items: result.Matches[offset:end],
HasMore: end < len(result.Matches),
}
if cursorResult.HasMore {
cursorResult.NextCursor = strconv.Itoa(end)
}
return cursorResult, nil
}
return nil, agent.ErrDocNotFound
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Make this mock mirror the real doc-search cursor contract.

SearchDocFeed/SearchDocMatches pass cursors through opaquely, but this mock uses raw IDs/decimal offsets instead of the encoded cursor format the real store returns. It also turns “document exists but query has no hits” into agent.ErrDocNotFound, and strconv.Atoi("-1") can drive offset negative and panic when slicing.

Please encode/decode with exec.{EncodeSearchCursor,DecodeSearchCursor} here as well, and look up the target doc first so a hitless doc returns an empty CursorResult.

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

In `@internal/service/frontend/api/v1/docs_test.go` around lines 220 - 277, The
mock implementations of SearchCursor and SearchMatches must mirror the real
store: use exec.EncodeSearchCursor/DecodeSearchCursor to decode incoming
opts.Cursor and to set NextCursor (not raw IDs or decimal offsets), and ensure
SearchMatches first finds the target document by id and returns an empty
CursorResult (not ErrDocNotFound) when the doc exists but has no matching hits;
also guard against negative offsets from strconv.Atoi by clamping to 0 and to
len(matches) before slicing. Update mockDocStore.SearchCursor and
mockDocStore.SearchMatches to decode/encode cursors with
exec.DecodeSearchCursor/exec.EncodeSearchCursor, lookup the document before
paging, and clamp computed offsets to [0,len(results or matches)].

Comment on lines +83 to +91
func toDAGSearchPageItem(item exec.SearchDAGResult) api.DAGSearchPageItem {
return api.DAGSearchPageItem{
FileName: item.FileName,
Name: item.FileName,
HasMoreMatches: item.HasMoreMatches,
NextMatchesCursor: optionalString(item.NextMatchesCursor),
Matches: toSearchMatchItems(item.Matches),
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check if exec.SearchDAGResult has a separate Name field that should be used.

ast-grep --pattern $'type SearchDAGResult struct {
  $$$
}'

Repository: dagu-org/dagu

Length of output: 369


🏁 Script executed:

ast-grep --pattern $'type DAGSearchPageItem struct {
  $$$
}'

Repository: dagu-org/dagu

Length of output: 973


Consider whether SearchDAGResult should have a separate Name field.

The Name field in api.DAGSearchPageItem is documented as "Display name for the DAG result," distinct from FileName. However, both are currently set to item.FileName because exec.SearchDAGResult lacks a separate Name field. Either add a Name field to SearchDAGResult to provide a proper display name, or clarify if this duplication is intentional.

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

In `@internal/service/frontend/api/v1/search.go` around lines 83 - 91, The mapping
sets both Name and FileName to item.FileName because exec.SearchDAGResult
currently lacks a separate display name; update exec.SearchDAGResult to include
a Name (or DisplayName) field and populate it where SearchDAGResult values are
constructed, then change toDAGSearchPageItem to set Name: item.Name (or
item.DisplayName) while keeping FileName: item.FileName so api.DAGSearchPageItem
reflects a proper display name distinct from the file path; alternatively, if
duplication was intentional, add a clarifying comment in toDAGSearchPageItem
explaining why Name mirrors FileName.

Comment on lines +23 to +25
onPageLimitChange?: (pageLimit: number) => void;
/** Whether to show the page-size selector */
showPageLimitSelector?: boolean;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Hide the page-size selector when there is no change handler.

onPageLimitChange is optional now, but the selector still renders by default. In that state every preset/custom entry is a no-op, so the UI looks interactive while doing nothing.

♻️ Small fix
 const DAGPagination = ({
   totalPages,
   page,
   pageChange,
   pageLimit,
   onPageLimitChange,
-  showPageLimitSelector = true,
+  showPageLimitSelector = !!onPageLimitChange,
 }: DAGPaginationProps) => {
@@
-      {showPageLimitSelector && (
+      {showPageLimitSelector && onPageLimitChange && (

Also applies to: 217-218, 239-246, 270-321

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

In `@ui/src/features/dags/components/common/DAGPagination.tsx` around lines 23 -
25, DAGPagination currently renders the page-size selector even when there is no
onPageLimitChange handler, making the UI appear interactive without effect;
update the rendering conditions so the selector (and its presets/custom entries)
is only rendered/enabled when showPageLimitSelector is true AND
onPageLimitChange is provided—i.e., guard the selector render and any
interactive entries with a check like (showPageLimitSelector && typeof
onPageLimitChange === 'function') in the DAGPagination component so that all
places that render the page-size selector (including the blocks around the
presets/custom entries and any dropdown items) are hidden or disabled when the
handler is absent.

Comment on lines +1 to +5
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import SearchResult from '../SearchResult';
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add the GPL header to this new test file.

This TSX file is missing the required license banner. Please run make addlicense here too.

As per coding guidelines, **/*.{go,ts,tsx,js}: Apply GPL v3 license headers on source files, managed via make addlicense.

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

In `@ui/src/features/search/components/__tests__/SearchResult.test.tsx` around
lines 1 - 5, This test file (SearchResult.test.tsx) is missing the required GPL
v3 license header; fix it by adding the GPL v3 banner to the top of the file
(same style used across TS/TSX/JS sources) or simply run the repository tooling:
execute make addlicense in this directory to apply the correct header
automatically so the imports and symbols like SearchResult, render, screen,
userEvent remain unchanged.

Comment on lines +119 to +130
<div className="flex items-center justify-between gap-4">
<Link to={link}>
<h3 className="text-lg font-semibold text-foreground">
{title}
<span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-xs font-normal text-muted-foreground">
{kind}
</span>
</h3>
</Link>
<span className="text-xs text-muted-foreground">
{matches.length} shown
</span>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Let long titles wrap instead of blowing out the result row.

The left side of this justify-between header can't shrink today, so long DAG names/doc titles can push the “shown” counter off-screen. Add min-w-0 on the link container and whitespace-normal break-words on the heading text.

🎨 Small layout fix
-        <div className="flex items-center justify-between gap-4">
-          <Link to={link}>
-            <h3 className="text-lg font-semibold text-foreground">
+        <div className="flex items-start justify-between gap-4">
+          <Link to={link} className="block min-w-0">
+            <h3 className="text-lg font-semibold text-foreground whitespace-normal break-words">
               {title}
               <span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-xs font-normal text-muted-foreground">
                 {kind}
               </span>
             </h3>
           </Link>
-          <span className="text-xs text-muted-foreground">
+          <span className="shrink-0 text-xs text-muted-foreground">
             {matches.length} shown
           </span>

As per coding guidelines, ui/**/*.{ts,tsx}: Always handle long text in tables and lists with whitespace-normal break-words to prevent layout overflow.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className="flex items-center justify-between gap-4">
<Link to={link}>
<h3 className="text-lg font-semibold text-foreground">
{title}
<span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-xs font-normal text-muted-foreground">
{kind}
</span>
</h3>
</Link>
<span className="text-xs text-muted-foreground">
{matches.length} shown
</span>
<div className="flex items-start justify-between gap-4">
<Link to={link} className="block min-w-0">
<h3 className="text-lg font-semibold text-foreground whitespace-normal break-words">
{title}
<span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-xs font-normal text-muted-foreground">
{kind}
</span>
</h3>
</Link>
<span className="shrink-0 text-xs text-muted-foreground">
{matches.length} shown
</span>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/features/search/components/SearchResult.tsx` around lines 119 - 130,
The header in the SearchResult component (the Link wrapping the <h3> that
renders title and kind) can overflow and push the matches counter off-screen;
update the Link container inside SearchResult to include a min-w-0 utility and
add whitespace-normal and break-words utilities to the heading that renders
title (the <h3> using title and kind) so long titles wrap correctly and prevent
layout overflow while preserving the existing justify-between layout.

Comment on lines +3 to +8
import SearchResult from '@/features/search/components/SearchResult';
import { useInfinite } from '@/hooks/api';
import { Search as SearchIcon } from 'lucide-react';
import React, { useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import React, { useEffect, useMemo, useRef } from 'react';
import { useLocation, useSearchParams } from 'react-router-dom';
import { ToggleButton, ToggleGroup } from '../../components/ui/toggle-group';
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add the required GPL header to this TSX source file.

This file is being modified, but it still lacks the repository's license header. Please run make addlicense as part of this change.

As per coding guidelines, "Apply GPL v3 license headers on source files, managed via make addlicense".

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

In `@ui/src/pages/search/index.tsx` around lines 3 - 8, This TSX file is missing
the required GPL v3 license header; add the repository's standard GPL header at
the very top of the file (before any imports) by running the repo toolchain
command `make addlicense` (preferred) or by inserting the exact header used
across the codebase, ensuring it appears above the import block that contains
symbols like SearchResult, useInfinite, SearchIcon, useLocation/useSearchParams
and ToggleButton/ToggleGroup so the file complies with the project's licensing
guidelines.

Comment on lines +426 to +477
<Input
placeholder="Search text..."
className="max-w-md"
ref={inputRef}
value={searchVal}
onChange={(e) => {
setSearchVal(e.target.value);
}}
type="search"
onKeyDown={(e) => {
if (e.key === 'Enter' && searchVal.trim()) {
onSubmit(searchVal);
}
}
}}
/>
<Button
disabled={!searchVal}
onClick={async () => {
onSubmit(searchVal);
}}
>
<SearchIcon className="h-4 w-4" />
Search
</Button>
}}
/>
<Button
disabled={!searchVal.trim()}
onClick={() => {
onSubmit(searchVal);
}}
>
<SearchIcon className="h-4 w-4" />
Search
</Button>
</div>

<ToggleGroup aria-label="Search scope">
<ToggleButton
value="dags"
groupValue={currentFilters.scope}
onClick={() => {
syncFilters({
searchVal: currentFilters.searchVal,
scope: 'dags',
});
}}
>
DAGs
</ToggleButton>
<ToggleButton
value="docs"
groupValue={currentFilters.scope}
onClick={() => {
syncFilters({
searchVal: currentFilters.searchVal,
scope: 'docs',
});
}}
>
Docs
</ToggleButton>
</ToggleGroup>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

The search controls are wired to the last submitted query, not the draft input.

Blocking empty submit means users can't clear an existing search, and the scope toggles re-submit currentFilters.searchVal, which overwrites any text typed after the last submit. buildSearchParams() already supports the empty-query state, so these controls should submit the current searchVal consistently.

Proposed fix
             <Input
               placeholder="Search text..."
               className="max-w-md"
               ref={inputRef}
               value={searchVal}
               onChange={(e) => {
                 setSearchVal(e.target.value);
               }}
               type="search"
               onKeyDown={(e) => {
-                if (e.key === 'Enter' && searchVal.trim()) {
+                if (e.key === 'Enter') {
                   onSubmit(searchVal);
                 }
               }}
             />
             <Button
-              disabled={!searchVal.trim()}
+              disabled={!searchVal.trim() && !submittedQuery}
               onClick={() => {
                 onSubmit(searchVal);
               }}
             >
               <SearchIcon className="h-4 w-4" />
               Search
@@
             <ToggleButton
               value="dags"
               groupValue={currentFilters.scope}
               onClick={() => {
                 syncFilters({
-                  searchVal: currentFilters.searchVal,
+                  searchVal,
                   scope: 'dags',
                 });
               }}
             >
@@
             <ToggleButton
               value="docs"
               groupValue={currentFilters.scope}
               onClick={() => {
                 syncFilters({
-                  searchVal: currentFilters.searchVal,
+                  searchVal,
                   scope: 'docs',
                 });
               }}
             >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/pages/search/index.tsx` around lines 426 - 477, The search controls
currently use the last submitted query (currentFilters.searchVal) and block
empty submits; update the Input, Button and ToggleButton handlers to use the
live draft state (searchVal) and allow empty submissions so users can clear
searches: remove the trim-based guard in the Input onKeyDown (call
onSubmit(searchVal) on Enter unconditionally), ensure the Button is not disabled
based on searchVal.trim() (allow click to call onSubmit(searchVal)), and change
both ToggleButton onClick calls to pass syncFilters({ searchVal: searchVal,
scope: 'dags' }) / syncFilters({ searchVal: searchVal, scope: 'docs' }) instead
of currentFilters.searchVal; keep buildSearchParams usage unchanged since it
already supports empty queries.

@yottahmd yottahmd merged commit 7e91b40 into main Apr 2, 2026
7 checks passed
@yottahmd yottahmd deleted the search-ui-cursor branch April 2, 2026 00:50
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 2, 2026

Codecov Report

❌ Patch coverage is 75.00000% with 99 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.61%. Comparing base (b6c214f) to head (f581b27).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
internal/persis/filedoc/store.go 68.29% 31 Missing and 21 partials ⚠️
internal/persis/filedag/store.go 73.01% 21 Missing and 13 partials ⚠️
internal/persis/filedag/grep/grep.go 88.29% 5 Missing and 6 partials ⚠️
internal/core/exec/search_cursor.go 83.33% 1 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1945      +/-   ##
==========================================
+ Coverage   68.60%   68.61%   +0.01%     
==========================================
  Files         462      463       +1     
  Lines       58359    58751     +392     
==========================================
+ Hits        40035    40313     +278     
- Misses      14636    14706      +70     
- Partials     3688     3732      +44     
Files with missing lines Coverage Δ
internal/agent/doc.go 100.00% <ø> (ø)
internal/core/exec/search_cursor.go 83.33% <83.33%> (ø)
internal/persis/filedag/grep/grep.go 90.34% <88.29%> (-6.02%) ⬇️
internal/persis/filedag/store.go 71.05% <73.01%> (+0.55%) ⬆️
internal/persis/filedoc/store.go 80.18% <68.29%> (-4.05%) ⬇️

... and 13 files with indirect coverage changes


Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update b6c214f...f581b27. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.

1 participant