Polymorphic recent-items bar + command center expansion#526
Conversation
Replaces the projects-only recent_project_views table with a polymorphic
recent_views(user_id, entity_type, entity_id, guild_id, last_viewed_at)
table. A BEFORE-trigger populates guild_id from the underlying entity;
RLS policies scope SELECT/INSERT/UPDATE/DELETE to current_guild_id and a
RESTRICTIVE policy locks rows to their own user. Migration copies
existing project view rows over before dropping the legacy table.
New GET /api/v1/recents returns the user's 20 most-recently-viewed
guild-scoped items across projects, documents, queues, and counter
groups, ordered by last_viewed_at desc, enriched with the data needed to
render entity-specific icons in one round trip. Per-entity POST/DELETE
{id}/view endpoints are added to documents, queues, and counter groups,
and the existing projects endpoints now use the shared
recent_views_service (the projects-only GET /projects/recent is removed).
Guild admins bypass DAC during enrichment for queues and counter groups
to match the per-entity detail-page behaviour, so a row the admin
successfully recorded a view for isn't silently dropped on read.
Seed script (scripts/seed_dev_data.py) updated to use the new RecentView
model and composite key shape.
Renames ProjectTabsBar to RecentTabsBar and generalises it across projects, documents, queues, and counter groups. Items come from the new useRecents() hook backed by /api/v1/recents and render an entity- specific icon: project emoji, getDocumentIcon/getDocumentIconColor for documents (matching document lists), GalleryHorizontalEnd for queues, Gauge for counter groups. New lib/recentIcon.tsx and lib/recentRoute.ts encapsulate the per-type icon rendering and route resolution (incl. getActiveRecentKey for highlighting the active tab). Detail pages for documents, queues, and counter groups now fire useRecordRecentView(entityType) on load, matching the existing pattern in ProjectDetailPage. useClearRecentView replaces the projects-only useClearProjectView and dispatches to the right entity-type DELETE on the bar's close button. Includes a RecentTabsBar.test.tsx covering mixed icon rendering, link routing, and onClose dispatch, plus a recent.factory for builders.
Suggested group now lists the top 5 mixed recent items (projects, documents, queues, counter groups) ordered by last_viewed_at, reusing renderRecentIcon and recentRoute so icons + routes match the layout tabs bar. Adds Queues and Counter Groups groups to the command palette backed by useQueuesList / useCounterGroupsList (page_size 100, 60s staleTime to match the existing Projects/Documents fetches). Each item links to its guild-scoped detail page with the matching lucide icon (GalleryHorizontalEnd / Gauge). i18n: adds groups.queues + groups.counterGroups to en/fr/es command namespaces.
|
@greptile |
Greptile SummaryThis PR replaces the projects-only sticky tabs bar and command-center "Suggested" group with a fully polymorphic implementation that surfaces recently viewed projects, documents, queues, and counter groups. A new
Confidence Score: 5/5Safe to merge. The migration, RLS policies, permission checks, and service layer are all implemented correctly, with passing tests covering mixed ordering, guild scoping, and clear/record round-trips. The core data-flow path — trigger-populated guild_id, RESTRICTIVE self-scope RLS, batch entity enrichment with per-entity permission checks — is correct. The two findings are edge-case routing and a gradual UX degradation when entities are deleted, neither of which blocks the feature from working correctly under normal use. frontend/src/lib/recentRoute.ts for the activeGuildId vs item.guild_id routing concern; entity deletion handlers for the orphaned-row cleanup gap. Important Files Changed
Sequence DiagramsequenceDiagram
participant Page as Detail Page
participant FE as useRecordRecentView
participant API as POST /{entity}/{id}/view
participant Svc as recent_views_service
participant DB as recent_views (PostgreSQL)
participant Bar as RecentTabsBar
participant Recents as GET /api/v1/recents
Page->>FE: mutate(entityId) on load
FE->>API: "POST /{entity}/{id}/view"
API->>Svc: record_view(user_id, entity_type, entity_id)
Svc->>DB: INSERT ON CONFLICT DO UPDATE last_viewed_at
DB-->>DB: BEFORE trigger sets guild_id from entity table
DB-->>DB: "RLS WITH CHECK guild_id = current_guild_id"
Svc->>DB: SELECT row to fetch last_viewed_at
Svc->>DB: Prune rows beyond offset 20
API-->>FE: RecentViewWrite
FE->>FE: invalidateRecents()
Bar->>Recents: GET /api/v1/recents
Recents->>DB: SELECT recent_views scoped by user + guild RLS
Recents->>DB: Batch-fetch entities by type
Recents->>Recents: Enrich + permission-filter each row
Recents-->>Bar: RecentItemRead[] ordered by last_viewed_at DESC
Bar-->>Page: Render tabs with entity-specific icons
Prompt To Fix All With AIFix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
frontend/src/lib/recentRoute.ts:23-27
The function accepts `activeGuildId` from the caller's hook, but `item.guild_id` is already present in every `RecentItemRead` payload. When React Query serves stale recents from guild A for up to 30 seconds after the user switches to guild B, `activeGuildId` will be guild B's ID while `item.guild_id` is still guild A's — producing routes like `/g/guildBId/projects/guildAProjectId` that will 404. Using `item.guild_id` directly makes the route self-contained and avoids the mismatch.
```suggestion
export function recentRoute(item: RecentItemRead, activeGuildId?: number | null): string {
const segment = SEGMENT_BY_TYPE[item.entity_type];
const path = `/${segment}/${item.entity_id}`;
// Prefer the guild embedded in the item itself; fall back to the active guild
// only if needed (e.g. items fetched outside a guild context).
const guildId = item.guild_id ?? activeGuildId;
return guildId ? guildPath(guildId, path) : path;
}
```
### Issue 2 of 2
backend/app/services/recent_views.py:57-79
**Orphaned rows from deleted entities silently consume the 20-item cap**
`recent_views` has no FK on `entity_id`, so deleting a project, document, queue, or counter group leaves its view rows in place. `recents.py` gracefully skips those orphaned rows on read, but they still occupy slots in the 20-item window returned by `list_recent_views`. A user who viewed 20 items and had most of them deleted would see a nearly empty bar even though the underlying limit wasn't yet hit. The rows self-heal — each new `record_view` call prunes the oldest entry — but the UX degradation persists until enough new views are recorded. Adding per-entity cleanup in the deletion handlers (e.g. `DELETE FROM recent_views WHERE entity_type = 'project' AND entity_id = ?`) would prevent the gap.
Reviews (2): Last reviewed commit: "Stop double-escaping recent-item names" | Re-trigger Greptile |
- Narrow recents enrichment to catch HTTPException only so latent permission-helper bugs (MissingGreenlet, AttributeError, …) surface as 500s instead of silently truncating the bar. - Rename projects.tabsBar.closeProject → closeItem (en/fr/es) and generalise loadingRecent wording, since the bar now covers documents, queues, and counter groups too. - Drop the unnecessary double-cast on listRecentsApiV1RecentsGet — the generated client already returns the correct type.
|
@greptile |
SanitizedBaseModel's _sanitize_strings validator runs nh3.clean() on every str field when a model is constructed from a dict — which is what happens with kwargs construction. Other read schemas avoid this because FastAPI / endpoint helpers build them via model_validate(orm_instance), where the validator sees a non-dict and returns early. RecentItemRead was being built with kwargs in the /recents enrichment loop, so a project named 'Session Zero & Planning' came out as 'Session Zero & Planning' and React rendered the literal '&'. Switch to model_construct(), which skips all validators — appropriate here since the data comes from trusted DB columns that were already sanitized on input.
Problem
The sticky tabs bar at the top of the app only surfaced recently opened projects. Users move between projects, documents, queues, and counter groups within a guild, so the bar (and the command center's "Suggested" group) was leaving most of that navigation flow on the table.
What changed
Backend — polymorphic
recent_viewsrecent_project_viewswith a singlerecent_views(user_id, entity_type, entity_id, guild_id, last_viewed_at)table. ABEFORE INSERT/UPDATEtrigger populatesguild_idfrom the underlying entity (projects/documents/queues/counter_groups); aCHECKconstraint pins the allowed entity types.RESTRICTIVEself-scope policy (user_id = current_user_id OR is_superadmin).20260523_0088_create_recent_views.pycreates the table, copies existing project view rows over withentity_type='project', drops the legacy table, and includes a clean downgrade.GET /api/v1/recentsreturns the 20 most-recently-viewed items mixed by recency, enriched with the data needed to render the bar in one round trip (name, icon for projects, document_type/mime_type/original_filename for documents). Guild admins bypass DAC in enrichment for queues + counter groups so a view they successfully recorded doesn't get silently dropped on read.POST/DELETE /api/v1/{documents,queues,counter-groups}/{id}/viewendpoints; the existing project endpoints now use the sharedrecent_views_service.GET /projects/recentis removed (only consumer was the bar).scripts/seed_dev_data.py) updated for the new model.Frontend —
RecentTabsBar+ mixed recentsProjectTabsBar→RecentTabsBar, rendering an entity-specific icon per item: emoji for projects,getDocumentIcon/getDocumentIconColorfor documents,GalleryHorizontalEndfor queues,Gaugefor counter groups.useRecents,useRecordRecentView(entityType),useClearRecentViewreplace the projects-only hooks. Detail pages for documents, queues, and counter groups now fire the record on load (mirroringProjectDetailPage).lib/recentIcon.tsx(icon resolution) andlib/recentRoute.ts(guild-scoped routes +getActiveRecentKeyfor active-tab highlighting).Command center
Tests run
Manually verified end-to-end via curl + the dev server: POST
/queues/{id}/viewrecords the view, GET/recents/returns it at the top of the mixed list, the bar updates after a refresh, and the X on each tab clears the row.Schema / migration notes
alembic upgrade headto apply20260523_0088. The migration copies existingrecent_project_viewsrows intorecent_viewswithentity_type='project'before dropping the legacy table; downgrade restoresrecent_project_viewsand copies project rows back.GET /api/v1/projects/recent(removed). The only consumer was the layout tabs bar, which now usesGET /api/v1/recents.Screenshots
TODO before merging — header bar with mixed icons; command center Suggested group across types.