Empowering Your Notes: Fine-Grained AI Control and Smarter Action Tracking
Dive into how we implemented per-note AI enrichment settings and organized action points by their source, transforming a messy workflow into a streamlined, powerful experience.
Every developer knows the thrill of building new features, especially when they directly address user pain points. Recently, our team tackled two significant enhancements designed to bring more control and clarity to how users interact with AI-powered note enrichment and action point management. The goal? Give users granular control over their AI, and make sure action points always lead back to their original context.
This past development session was a whirlwind of database schema updates, backend API extensions, and intricate frontend UI work. Here's a deep dive into what we built, the challenges we overcame, and what's next on our roadmap.
The Vision: Granular AI and Contextual Actions
Our users loved the AI note enrichment feature, but they wanted more control. "Can I pick a different model?" "What if I want a specific persona for this note?" These questions highlighted a need for a more sophisticated interface. Simultaneously, action points generated by AI or created manually often felt disconnected from their origin. We needed a way to group these actionable insights by the specific note that spawned them.
So, our two main objectives for this session were:
- Implement a provider/model/persona chooser for AI note enrichment.
- Group action points by their source note on the project detail page.
Both features are now fully implemented, type-checked, and lint-clean – ready to be committed!
Building Blocks: Database, Backend, and Frontend
Let's walk through the technical journey, from the database up to the user interface.
1. Database Schema: Linking Actions to Notes
The foundation for grouping action points by their source note began in our prisma/schema.prisma file. We needed a way to explicitly link an ActionPoint record back to the ProjectNote that generated it.
We achieved this by:
- Adding a nullable
sourceNoteIdforeign key to theActionPointmodel. - Defining a
sourceNoterelation onActionPointto easily fetch the related note. - Adding an
actionPoints ActionPoint[]back-relation on theProjectNotemodel, allowing us to query all action points associated with a specific note.
After these schema changes, npm run db:push && npm run db:generate ensured our database and Prisma client were perfectly in sync.
2. Backend Logic: Empowering Enrichment and Data Retrieval
With the database ready, we turned our attention to the server-side logic:
- Enrichment Service (
src/server/services/note-enrichment.ts): The coreenrichNoteWithWisdom()function was extended to accept optionalproviderName,modelOverride, andpersonaIdparameters. This is where the magic of user-selected AI configurations happens. - Security for Personas: Crucially, we scoped persona queries to either the user's tenant or built-in system personas. This prevents any cross-tenant information disclosure, a vital security consideration.
- tRPC Procedures (
src/server/trpc/routers/projects.ts):- The
enrichprocedure was updated to pass the newly availableprovider,modelOverride, andpersonaIdfrom the frontend to our enrichment service. - The
applyEnrichmentprocedure (which commits the generated action points) was modified to ensuresourceNoteId: input.noteIdis passed when creating new action points. This populates our new database field.
- The
- Action Point Retrieval (
src/server/trpc/routers/action-points.ts): To enable frontend grouping, thelistprocedure now includes thesourceNote: { select: { id, title } }relation. This ensures that when we fetch action points, we also get the necessary details of their originating note.
3. Frontend UI: Intuitive Controls and Organized Views
The user-facing part of these features required significant work in our Next.js application:
- Per-Note Enrichment Settings (
src/app/(dashboard)/dashboard/projects/[id]/page.tsx):- We built a sleek, collapsible "Enrichment Settings" UI right within each note's section in the Notes tab.
- This includes buttons for selecting AI providers, a grid for choosing specific models, and a
PersonaPickercomponent. - Crucially, these settings are managed via a
Map<noteId, EnrichSettings>for per-note state. This means configuring enrichment for one note doesn't inadvertently affect others – a lesson we learned the hard way (more on that below!). - These settings are then passed directly to the
enrichNote.mutate()call.
- Action Point Grouping (
ActionPointGroupscomponent):- In the Action Points tab, we created a new component that dynamically groups action points by their
sourceNoteId. - Each group gets a collapsible header displaying the source note's title and a count badge for the number of action points.
- A dedicated "Manual / Other" section catches any action points without a
sourceNoteId(e.g., manually created ones). - A flat list fallback is provided if no grouping is possible or desirable.
- Careful attention was paid to React
keyplacement for optimal rendering performance and stability.
- In the Action Points tab, we created a new component that dynamically groups action points by their
Challenges & Lessons Learned
No development session is complete without a few head-scratchers. Here are two significant challenges and how we resolved them:
1. The Per-Note State Dilemma
- Initial Approach: We first tried to manage the enrichment settings (provider, model, persona) using a single
useStatehook, intending for it to be shared across all notes. - The Problem: During a code review, it became immediately clear that this approach was flawed. Opening the enrichment settings for one note would inadvertently apply those same settings to all notes simultaneously, leading to a confusing and broken user experience.
- The Solution: We refactored the state management to use a
Map<noteId, EnrichSettings>. This allowed us to store and retrieve unique enrichment settings for each individual note, ensuring that changes to one note's settings did not affect any others. We built helper functions likegetEnrichSettings()andpatchEnrichSettings()to manage this map efficiently. - Lesson: When building highly interactive UIs with multiple instances of similar components, carefully consider the scope of your state. Global or shared state isn't always the answer; sometimes, granular, instance-specific state is paramount for a predictable user experience.
2. React key Placement: A Common Pitfall
- Initial Approach: When rendering our grouped action points, we had a
renderItemhelper function that returned a<Card>component. We initially placed thekeyprop inside thisrenderItemfunction, on the<Card>itself. - The Problem: React was ignoring the
keyprop. When usingitems.map((ap) => renderItem(ap)), React expects thekeyto be on the direct child of the element returned by themapcallback, not nested inside a helper function's return value. - The Solution: We moved the
keyprop to themapcall site:{items.map((ap) => <div key={ap.id}>{renderItem(ap)}</div>)}. This ensures React correctly identifies each item in the list for efficient updates. - Lesson: Always remember that React
keyprops must be placed on the immediate children of the element returned by amapor similar iteration function. This is fundamental for React's reconciliation algorithm to function correctly.
What's Next?
With these features implemented, our immediate next steps involve:
- Committing the changes: A substantial commit covering 16 modified files, combining this feature with some prior uncommitted work.
- Code Review Feedback: Addressing suggestions like validating
providerinput against a known enum (z.enum(LLM_PROVIDERS)) for robustness, and addinguseMemoto the action point grouping logic for performance optimization. - Model Expansion: Considering the addition of
ollamato ourFAST_MODELSconstant to expand our available AI options. - Security Enhancement: Adding a tenant/project scope check to the
projectNote.updatewhere clause in theapplyEnrichmentprocedure for an extra layer of security. - End-to-End Testing: A thorough test to ensure everything works as expected: expanding a note, configuring enrichment, enriching, applying, verifying
sourceNoteIdpopulation, and checking the grouped view in the Actions tab.
This session was a significant step forward, bringing powerful new capabilities and a smoother, more intuitive experience to our users. We're excited to see these features in action and continue iterating on them!