* feat(web): tabbed multi-PR CI popover for topbar button and chat status chip
* test(e2e): cover multi-PR CI popover tabs, chip aggregate, and dropdown
* fix(web): address multi-PR popover review feedback
- suppress hover popover while the PR dropdown is open (no stacked overlays)
- repo-scope tab testids so same-number PRs on different repos stay unique
- guard non-array taskPRs store value in PRStatusChip (same as PRTaskIcon)
- add tablist/tab/aria-selected semantics to the popover tabs
* fix(web): warm default PR only, skip terminal PRs, add tabpanel ARIA
- chip background-sync warms pickDefaultPR (the tab the popover opens), not list head
- PRFeedbackWarmer skips merged/closed PRs (no wasted fetches in topbar path)
- tabpanel role + aria-controls/labelledby wire tabs to the panel
* fix(web): complete ARIA tabs keyboard model in multi-PR popover
- roving tabindex: only the active tab in the page tab order
- ArrowLeft/ArrowRight move selection + focus within the tablist (wrapping)
- stable useId-scoped tabpanel id so every tab's aria-controls resolves
- document topbar-only scoping of prCheckGroup e2e helpers
* refactor(web): skip duplicate rank call for first PR in pickDefaultPR
* perf(web): eliminate sidebar close lag with many tasks
Closing the app sidebar unmounted the entire task list in the same frame
the width animation started, so close lag scaled with task count.
- Keep the Tasks grow-section mounted but CSS-hidden in rail mode:
collapse is now O(1) and reopening is instant (no remount).
- Memoize onToggleGroup so TaskSwitcher's memo() holds; an inline arrow
re-rendered every task row on each sidebar render.
- Animate only width (transition-[width]) instead of transition-all.
- content-visibility:auto on task blocks skips layout/paint for rows
outside the sidebar scrollport in long lists.
* test(web): add round-trip collapse/expand assertion to sidebar section test
* fix(linear): match issue identifier (ENG-123) in search
Linear's `searchableContent` filter never matches the issue identifier
(team key + number), so a query like `ENG-123` from the Linear view
returned no results. When the query parses as a `TEAM-NUM` identifier,
OR-combine the content match with `{team.key.eq, number.eq}` so the
exact issue surfaces while other filters keep AND-joining. Lowercase
inputs (`eng-123`) are normalized to the upper-case team key.
Fixes#1339
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(linear): mention ID in search box placeholder
Reflect the backend's new identifier-aware search by hinting at it in
the search input on `/linear`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs(linear): explain why identifier search keeps both OR branches
claude[bot] flagged the searchableContent branch in the identifier OR
as functionally dead and the surrounding comment as a sentence fragment.
The branch is intentional — it catches cross-references like "duplicate
of ENG-123" pasted into another issue's title or description, which the
exact team+number branch would miss. Rewrite the comment to explain
that, fixing the fragment in the same change.
* fix(linear): use canonical identifier form in searchableContent OR branch
greptile flagged that the `searchableContent.contains` branch in the
identifier OR was sent verbatim from user input. When the user typed
`eng-123`, Linear's case-sensitive `contains` filter would miss
cross-references written in the canonical form `ENG-123`. Normalise
the contains value to the same upper-cased identifier we use for the
exact `team+number` branch, and lock that behaviour in
`TestBuildIssueFilter_IdentifierLowercase`.
* fix(linear): use containsIgnoreCase for searchableContent
@carlosflorencio flagged that canonicalising the identifier OR branch
to upper case meant lowercase literal references (e.g. "see eng-123"
pasted into a description) no longer matched. The previous round had
swapped the other way at greptile's request to fix uppercase
references. Both directions are right — the underlying issue is that
Linear's `contains` filter is case-sensitive. Switch the whole
`searchableContent` filter (identifier OR branch and the non-identifier
free-text path) to `containsIgnoreCase`, so casing of the user input
and of the indexed text no longer matters in either direction.
Free-text search is now case-insensitive too, which is the intuitive
default for a search box.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(task): cascade-archive child tasks on workflow delete
Tasks pointing at a deleted workflow_id silently became unreachable
because the FK on tasks.workflow_id was dropped to support ephemeral
quick-chat tasks, so SQLite cannot CASCADE. Service.DeleteWorkflow
now archives every active child task before removing the workflow,
re-using the existing ArchiveTask path (events, worktree cleanup,
agent stop). Frontend confirm copy and button label updated to reflect
the new behavior.
Closes#1279
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(task): make workflow-delete cascade race-safe
Address review feedback (Greptile, Claude, cubic): ArchiveTask now returns
a typed ErrTaskAlreadyArchived sentinel so DeleteWorkflow can swallow the
TOCTOU race where a task is archived between ListTasks and ArchiveTask.
Without the sentinel, that race would propagate as an opaque error and
leave the workflow row intact even though every task in the snapshot was
already in the desired state.
Tests:
- TestService_DeleteWorkflow_SkipsConcurrentlyArchivedTask covers the
sentinel-skip path through the cascade.
- TestService_DeleteWorkflow_PartialArchiveErrorPreservesWorkflow covers
the fail-fast contract on a real (non-sentinel) error: workflow row
survives, previously-archived tasks stay archived, retry is safe.
- TestService_ArchiveTask_ReturnsAlreadyArchivedSentinel locks in the
sentinel-error contract DeleteWorkflow relies on.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(stats): add avg turn duration and messages per turn to averages card
Adds two new metrics to the workspace stats Averages card: mean turn
duration and mean message count per turn. Both exclude outliers — turns
shorter than 1s (no-op/aborted), longer than 1h (zombie turns where
completed_at was backfilled across an agent restart), and turns with no
messages — so the displayed means reflect typical work rather than tail
skew. On a ~4k-turn sample the filter drops avg duration from ~1062s to
~289s.
* fix(stats): exclude exact 1h turns and use Tooltip for averages help text
Address PR feedback: the BETWEEN bound was inclusive so turns exactly at
the 1h ceiling were retained, contradicting the documented half-open
range. Also swap the title attribute on the new Averages rows for the
shared Tooltip component so the help text is reachable via keyboard and
screen readers.
* fix(stats): align turn-row zero state with em-dash convention
Address PR feedback: avg_messages_per_turn rendered "0.0" while
avg_turn_duration_ms rendered "—" under the same empty-data condition.
Also corrects the GetGlobalStats docstring to acknowledge that
clean_turn_agg adds one correlated per-turn lookup on top of the four
single-scan CTEs.
* test(stats): cover exact 1h turn boundary in clean-turn exclusion test
Add a turn of exactly 3,600,000ms (cleanTurnMaxDurationMs) to
TestGetGlobalStats_CleanTurnExcludesOutliers so the exclusive upper
bound is exercised directly, not just via the 2h zombie turn that
sits well above the boundary.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>