nyxcore-systems
6 min read

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.

AILLMProductivityFullstackTypeScriptNext.jsPrismaUXDeveloperExperience

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:

  1. Implement a provider/model/persona chooser for AI note enrichment.
  2. 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 sourceNoteId foreign key to the ActionPoint model.
  • Defining a sourceNote relation on ActionPoint to easily fetch the related note.
  • Adding an actionPoints ActionPoint[] back-relation on the ProjectNote model, 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 core enrichNoteWithWisdom() function was extended to accept optional providerName, modelOverride, and personaId parameters. 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 enrich procedure was updated to pass the newly available provider, modelOverride, and personaId from the frontend to our enrichment service.
    • The applyEnrichment procedure (which commits the generated action points) was modified to ensure sourceNoteId: input.noteId is passed when creating new action points. This populates our new database field.
  • Action Point Retrieval (src/server/trpc/routers/action-points.ts): To enable frontend grouping, the list procedure now includes the sourceNote: { 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 PersonaPicker component.
    • 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 (ActionPointGroups component):
    • 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 key placement for optimal rendering performance and stability.

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 useState hook, 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 like getEnrichSettings() and patchEnrichSettings() 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 renderItem helper function that returned a <Card> component. We initially placed the key prop inside this renderItem function, on the <Card> itself.
  • The Problem: React was ignoring the key prop. When using items.map((ap) => renderItem(ap)), React expects the key to be on the direct child of the element returned by the map callback, not nested inside a helper function's return value.
  • The Solution: We moved the key prop to the map call 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 key props must be placed on the immediate children of the element returned by a map or 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:

  1. Committing the changes: A substantial commit covering 16 modified files, combining this feature with some prior uncommitted work.
  2. Code Review Feedback: Addressing suggestions like validating provider input against a known enum (z.enum(LLM_PROVIDERS)) for robustness, and adding useMemo to the action point grouping logic for performance optimization.
  3. Model Expansion: Considering the addition of ollama to our FAST_MODELS constant to expand our available AI options.
  4. Security Enhancement: Adding a tenant/project scope check to the projectNote.update where clause in the applyEnrichment procedure for an extra layer of security.
  5. End-to-End Testing: A thorough test to ensure everything works as expected: expanding a note, configuring enrichment, enriching, applying, verifying sourceNoteId population, 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!