Messages Feature
Overview
The Messages feature is a two-panel email-client style inbox for reviewing and replying to comments across all applications in a workspace.
- Global inbox:
/o/:orgSlug/messages— all applications, all categories - Per-app inbox:
/o/:orgSlug/applications/:applicationId/messages— same UI, scoped to one application (flat left tree, no app-level header)
Both routes render the same MessagesPage component with an optional applicationId prop.
UI Structure
┌──────────────────────────────────────────────────────────────────────┐
│ Messages │
│ ┌──────────────────────┐ ┌────────────────────────────────────────┐ │
│ │ LEFT: Thread Tree │ │ RIGHT: Thread Detail │ │
│ │ (40% width) │ │ (flex-1) │ │
│ │ │ │ │ │
│ │ [Search input] │ │ Breadcrumb › Category › Slot │ │
│ │ [All][Unread][★Reply]│ │ ● Applicant pills [Status badge] │ │
│ │ [Internal][External] │ │ [★ Needs Reply banner] │ │
│ │ │ │ ───────────────────────────────── │ │
│ │ ▼ Tax App 2026 [3●] │ │ [All][Ext][Int] [↓][↑] [Open ↗] │ │
│ │ Application (5) │ │ ───────────────────────────────── │ │
│ │ Documents (12)● │ │ comment list (scrollable) │ │
│ │ ├ Passport (3) ● │ │ ── New Messages ── │ │
│ │ └ Tax Return(0) │ │ ... │ │
│ │ Decision (0) │ │ ───────────────────────────────── │ │
│ │ Applicants (2) │ │ [Editor with file refs + Int/Ext] │ │
│ └──────────────────────┘ └────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
File Structure
Frontend
src/features/messages/
├── index.tsx # MessagesPage — main two-panel layout
├── types.ts # SelectedThread, ApplicationCommentSummary, CategoryCommentSummary, SlotCommentSummary
├── components/
│ ├── messages-filter-bar.tsx # Search input + filter tabs (All/Unread/NeedsReply/Internal/External)
│ ├── thread-tree.tsx # Left panel — application tree with category rows and slot sub-rows
│ └── thread-detail.tsx # Right panel — comment list with filters, sort, editor
└── hooks/
└── use-workspace-comments.ts # All data hooks for this feature
src/routes/_authenticated/o/$orgSlug/
├── messages/index.tsx # Global inbox route
└── applications/$applicationId/messages/index.tsx # Per-app inbox route
Backend
core/.../document/
├── WorkspaceCommentService.java # Summary aggregation, unread tracking, flat comment query
├── ApplicationCommentService.java # Per-app comment CRUD + connection query
├── domain/
│ ├── ApplicationComment.java # Has: category (enum), applicationDocumentDefinitionId
│ ├── ApplicationCommentCategory.java # Enum: APPLICATION | DOCUMENTS | DECISION | APPLICANTS
│ ├── ApplicationCommentRead.java # Unread tracking: userId + applicationId + category → lastReadAt
│ ├── ApplicationCommentReadRepository.java
│ └── ApplicationDocumentDefinitionRepository.java # findByApplicationIdIn() for slot list
└── web/
├── ApplicationCommentController.java # GraphQL mappings incl. workspaceCommentSummary, applicationCommentsConnection
└── dto/
├── ApplicationCommentSummaryDto.java # Includes applicants, documentsStatus, hasAttachments
├── CategoryCommentSummaryDto.java
├── SlotCommentSummaryDto.java # Includes type field
├── ApplicantSummaryForCommentDto.java # name + type (ApplicantType enum)
└── AppDocumentStatusSummaryDto.java # total, approved, uploaded, pending, rejected
ApplicationCommentSummaryDto extended fields (added to existing DTO):
List<ApplicantSummaryForCommentDto> applicants— sorted PRIMARY firstAppDocumentStatusSummaryDto documentsStatus— nullable, null if no documentsboolean hasAttachments— true if any comment content references a file
WorkspaceCommentService.buildAppSummary() enrichment:
applicants— reuses the existingapplicantRepository.findByApplicationIdInWithProfile()batch fetch; mapsApplicant.type+profile.getFullName()+ profile email; sorted byApplicantTypeordinal (PRIMARY first)documentsStatus— queriesApplicationDocumentRepositoryfor status counts grouped byapplicationId; maps toAppDocumentStatusSummaryDtohasAttachments— checks if any commentcontentstring containsdata-file-refs(simpleString.containscheck)
Data Types (Frontend)
// types.ts
type ApplicationCommentCategory = 'APPLICATION' | 'DOCUMENTS' | 'DECISION' | 'APPLICANTS'
interface SlotCommentSummary {
slotId: string // ApplicationDocumentDefinition.id
slotName: string
type: string | null // document slot type (e.g. 'PASSPORT', 'TAX_RETURN')
totalCount: number
unreadCount: number
needsReply: boolean // true if last comment in this slot is EXTERNAL
}
interface CategoryCommentSummary {
category: ApplicationCommentCategory
totalCount: number
unreadCount: number
needsReply: boolean // OR of all slot needsReply values
latestCommentAt: string | null
latestAuthorName: string | null
latestContentPreview: string | null
slots: SlotCommentSummary[] // only populated for DOCUMENTS category
}
// Enriched applicant info with type (PRIMARY, SECONDARY, etc.)
interface ApplicantSummaryForComment {
name: string
type: string // ApplicantType enum value: PRIMARY | SECONDARY | TERTIARY | FOURTH | FIFTH
email?: string | null
}
// Per-application document status counts
interface AppDocumentStatusSummary {
total: number
approved: number
uploaded: number
pending: number
rejected: number
}
interface ApplicationCommentSummary {
applicationId: string
applicationTitle: string
applicationStatus: string | null
applicationSlug: string
applicantNames: string[] // kept for fallback display; prefer applicants[] for rich display
applicants: ApplicantSummaryForComment[] // enriched list with type, sorted PRIMARY first
documentsStatus: AppDocumentStatusSummary | null
hasAttachments: boolean
categories: CategoryCommentSummary[]
totalUnread: number
}
interface SelectedThread {
applicationId: string
applicationTitle: string
applicationSlug: string
applicantNames: string[]
applicants?: ApplicantSummaryForComment[]
applicationStatus: string | null
documentsStatus?: AppDocumentStatusSummary | null
hasAttachments?: boolean
category: ApplicationCommentCategory | null
slotId: string | null // null = category overview, set = specific document slot
slotName: string | null
unreadCount: number
needsReply: boolean
}
GraphQL Queries & Mutations
workspaceCommentSummary(workspaceId: ID!)
Returns ApplicationCommentSummaryConnection — tree data for the left panel.
- Permission: any workspace role (client-scoped: only EXTERNAL comments on own applications)
slots[]inside DOCUMENTS category includes ALLApplicationDocumentDefinitionrecords for the application — even those with zero comments (so the left tree always shows all slots)applicantNamesresolved via batch join onApplicant.profile.fullName(kept for backwards compat)applicants[]— enriched list withname,type, sorted PRIMARY firstdocumentsStatus—{ total, approved, uploaded, pending, rejected }countshasAttachments— true if any comment has file references- Frontend:
useWorkspaceCommentSummary(workspaceId)— 30s refetch interval
GraphQL schema additions (comments.graphqls):
type ApplicantSummaryForComment {
name: String!
type: String!
}
type AppDocumentStatusSummary {
total: Int!
approved: Int!
uploaded: Int!
pending: Int!
rejected: Int!
}
# Added to ApplicationCommentSummary:
# applicants: [ApplicantSummaryForComment!]!
# documentsStatus: AppDocumentStatusSummary
# hasAttachments: Boolean!
applicationCommentsConnection(applicationId, category?, slotId?, last?, before?)
Cursor-based comment list for the right panel.
- Server returns newest-first (DESC) order
- Frontend reverses for "oldest first" (chronological) display:
sortAsc ? [...comments].reverse() : comments - Frontend:
useApplicationCommentsConnection(applicationId, { category, slotId, last: 100 })
workspaceUnreadCommentCount(workspaceId: ID!)
Scalar Int — total unread count for the sidenav badge.
- Frontend:
useWorkspaceUnreadCommentCount(workspaceId)— 30s refetch interval
applicationDocumentDefinitions(applicationId: ID!, first: 100)
Fetches all document slots for an application. Used in the editor to populate the file-reference attachment picker.
- Frontend:
useApplicationDocumentDefinitions(applicationId)
markCategoryCommentsAsRead(applicationId: ID!, category: ApplicationCommentCategory!)
Mutation — upserts ApplicationCommentRead.lastReadAt = now for the current user.
- Triggered automatically after 1.5s of viewing a thread in
ThreadDetail - On success: invalidates
workspaceCommentSummaryandworkspaceUnreadCommentCountquery keys
Hooks (use-workspace-comments.ts)
| Hook | Purpose |
|---|---|
useWorkspaceCommentSummary(workspaceId) | Left tree data — all app summaries with category/slot counts |
useWorkspaceUnreadCommentCount(workspaceId) | Sidenav badge count (30s poll) |
useApplicationCommentsConnection(applicationId, opts) | Right panel comment list (10s refetch) |
useApplicationDocumentDefinitions(applicationId) | Document slots as FileRef[] for editor |
useMarkCategoryAsRead(workspaceId) | Mutation — marks category read, invalidates summary keys |
All use TypedDocumentString (NOT the graphql() codegen function). Query keys from queryKeys.* in src/lib/query-keys.ts:
queryKeys.workspaceCommentSummary(workspaceId)
queryKeys.workspaceUnreadCommentCount(workspaceId)
queryKeys.applicationCommentsConnection(applicationId, category, slotId)
Left Panel — ThreadTree
Component: thread-tree.tsx
Props:
summaries: ApplicationCommentSummary[]— fromuseWorkspaceCommentSummaryfilter: MessageFilter— 'all' | 'unread' | 'needsReply' | 'internal' | 'external'search: string— filters by application titleflatMode?: boolean— whentrue(per-app mode) renders category rows directly without the application-level collapsible headerselectedThread: SelectedThread | nullonSelectCategory / onSelectSlot— setSelectedThreadstate in parent
Category order: APPLICATION → DOCUMENTS → DECISION → APPLICANTS
Click behaviour:
- Click category row →
slotId = null(overview mode: all slots stacked in right panel) - Click slot row (inside DOCUMENTS) →
slotId = <id>(focused mode: single slot in right panel)
Counts display (on category row):
(N)muted total count whentotalCount > 0- Orange
●dot whenneedsReply = true - Blue unread badge when
unreadCount > 0
ApplicationRow — Collapsed State (Rich Preview)
Applications are collapsed by default (useState(false)). The collapsed row shows:
- Application title
- Applicant badges — sorted PRIMARY first, each shows colored initials avatar +
typeshort label. Color scheme:PRIMARY=indigo,SECONDARY=teal,TERTIARY=violet,FOURTH=amber,FIFTH=rose - Application status chip
- Documents status — compact icon+count chips:
✓ approved · ↑ uploaded · ⏳ pending · ✗ rejected - Latest message preview — finds the category with max
latestCommentAt, shows"AuthorName: preview..."truncated - Paperclip icon — if
hasAttachments = true - Unread badge
SlotRow — Document Slot Row
SlotRow receives index: number and type?: string | null props:
- Slot number: zero-padded index (
01,02, …) in monospace muted text - Type label: uppercase monospace muted text (e.g.
PASSPORT) - Slot name: truncated
- Comment count: shown as dim number when
totalCount > 0
Right Panel — ThreadDetail
Component: thread-detail.tsx
Props (added):
onSelectThread?: (thread: SelectedThread) => void— allows navigation from inside the thread header (e.g. clicking the category breadcrumb to go back to the category overview)
Header (ThreadHeader) — 4-row layout:
Row 1 — Breadcrumb + Open link:
AppTitle > Category(category is a clickable link that callsonSelectThread({ ...thread, slotId: null, slotName: null })to navigate back to category level)- Right-aligned "Open in App ↗" link to
/o/:orgSlug/applications/:id/messages
Row 2 — Thread title:
- Prominent
<h3>showing slot name (if slot-focused) or category label (if category overview)
Row 3 — Applicants (left) + Status chips (right):
- Left: applicant cards using the same color scheme as
applicant-view-card.tsx:Avatarwith colored initials (bg-indigo-50 text-indigo-700for PRIMARY, etc.)- Bold uppercase colored type label
- Name + optional email
- Right (aligned under timestamp column):
- Application status badge
- Documents status summary (
✓ N · ↑ N · ⏳ N · ✗ N) - Paperclip icon + count if
hasAttachments
Row 4 — Needs Reply banner:
- Orange banner when
thread.needsReply = true
Applicant type color scheme (APPLICANT_TYPE_STYLE map in thread-detail.tsx):
PRIMARY: { text: 'text-indigo-700 dark:text-indigo-300', avatar: 'bg-indigo-50 text-indigo-700 dark:bg-indigo-950 dark:text-indigo-300' }
SECONDARY: { text: 'text-teal-700 dark:text-teal-300', avatar: 'bg-teal-50 text-teal-700 dark:bg-teal-950 dark:text-teal-300' }
TERTIARY: { text: 'text-violet-700 dark:text-violet-300', avatar: 'bg-violet-50 text-violet-700 dark:bg-violet-950 dark:text-violet-300' }
FOURTH: { text: 'text-amber-700 dark:text-amber-300', avatar: 'bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300' }
FIFTH: { text: 'text-rose-700 dark:text-rose-300', avatar: 'bg-rose-50 text-rose-700 dark:bg-rose-950 dark:text-rose-300' }
Filter bar:
- All / External (👁) / Internal (🔒) type filter — counts from
rawComments - Sort toggle: ↓ newest-first | ↑ oldest-first (persisted to
localStoragekeyams.messages.comments.sort)
Sort order logic (critical):
// Server returns DESC (newest first)
const displayedComments = sortAsc
? [...filteredComments].reverse() // sortAsc=true → oldest first (reverse server order)
: filteredComments // sortAsc=false → newest first (keep server order)
Scroll behaviour mirrors CommentTab: scrollTop = sortAsc ? scrollHeight : 0
"New Messages" divider:
- Only shown when
0 < unreadCount < totalCount(user has seen the thread before) - Position:
- oldest-first (
sortAsc=true):displayedComments.length - unreadCount(unread at end) - newest-first (
sortAsc=false):unreadCount - 1(unread at start)
- oldest-first (
Auto mark-as-read: 1.5s setTimeout triggers markCategoryCommentsAsRead mutation when thread.unreadCount > 0.
Editor: CommentEditor receives documents={docDefs} (from useApplicationDocumentDefinitions) enabling file-reference attachments in comments.
Reuses: CommentItem, CommentEditor, CommentFileRefs from src/features/applications/detail/messages/components/
MessagesPage — Layout
File: src/features/messages/index.tsx
Height: lg:h-[calc(100svh-112px)] — correct for a top-level page wrapped in <div className="p-4"> (64px fixed header + 48px page padding = 112px).
Do NOT use the
230pxoffset fromview2.tsx— that offset is for the application detail page which has a sub-header and tabs adding ~118px extra.
Per-app mode: Pass applicationId prop to scope the left tree to one application. The MessagesPage filters allSummaries and passes flatMode={true} to ThreadTree.
Sidenav Unread Badge
File: src/components/layout/app-sidebar.tsx
Uses useWorkspaceUnreadCommentCount(activeWorkspace?.id) (only for org workspaces). The count is passed to getOrganizationSidebarData(workspace, role, t, unreadCount) which sets badge: String(count) on the Messages nav item.
Badge shows for all roles (OWNER, ADMIN, MANAGER, MEMBER, CLIENT). The backend scopes client counts to EXTERNAL comments on their own applications.
Backend — Unread Tracking
Entity: ApplicationCommentRead — (userId, applicationId, category) → lastReadAt: Instant
WorkspaceCommentService.buildCategorySummary() computes unreadCount as:
comments.stream().filter(c -> lastReadAt == null || c.getCreatedAt().isAfter(lastReadAt)).count()
lastReadAt = null → all comments are unread (first time viewing).
WorkspaceCommentService.countWorkspaceUnread() — used by workspaceUnreadCommentCount query; sums unread across all applications and categories for the sidenav badge.
Backend — needsReply Logic
Slot-level: true when the most recent comment in a slot has type = EXTERNAL (client sent last, no staff reply after).
Category-level: OR of all slot-level needsReply values in that category. For APPLICATION / DECISION / APPLICANTS (no sub-slots), the whole category is treated as one implicit slot.
Backend — Document Slots in Left Tree
Key: ALL ApplicationDocumentDefinition records for an application are shown in the DOCUMENTS category left tree — even slots with zero comments. This is achieved by:
WorkspaceCommentServicebatch-fetches all slots viaApplicationDocumentDefinitionRepository.findByApplicationIdIn(appIds)- Slots are sorted by
sortOrderthen merged with comment data - Orphaned comment slots (referencing deleted definitions) are appended at the end
Backend — Applicant Names
Resolved in WorkspaceCommentService via batch query:
applicantRepository.findByApplicationIdInWithProfile(appIds)
// JOIN FETCH a.profile — one query for all apps, no N+1
profile.getFullName() returns firstName + " " + lastName.
Translation Keys
All in src/lib/i18n/translations/{en,de,ja}/messages.ts:
| Key | Usage |
|---|---|
messages.title | Page header |
messages.loading | Loading state in both panels |
messages.empty | Left tree when no results |
messages.selectThread | Right panel placeholder |
messages.newMessages | "── New Messages ──" divider |
messages.openInApp | Header link to application page |
messages.needsReply | Orange banner in thread header |
messages.search.placeholder | Filter bar search input |
messages.filter.all/unread/needsReply/internal/external | Left panel filter tabs |
messages.category.application/documents/decision/applicants | Category row labels |
Common Pitfalls
-
Sort order: Server returns DESC (newest first). To show oldest-first, you MUST reverse. Using
sortAsc ? filteredComments : reversewill show newest-first whensortAsc=true— that is WRONG. The correct form issortAsc ? reverse : filteredComments. -
Height offset: Use
lg:h-[calc(100svh-112px)]for this top-level page, not230px(that's for app detail context with sub-headers). -
TypedDocumentString: All queries use
new TypedDocumentString(...)directly. Do NOT use thegraphql()codegen function for this feature. -
"New Messages" divider guard: Only show when
0 < unreadCount < totalCount. IfunreadCount === totalCount(user never read the thread), show nothing — otherwise every comment appears "new". -
Applicant names empty: If
applicantNamesis[], the backend'sWorkspaceCommentServicebatch fetch may be failing. CheckApplicantRepository.findByApplicationIdInWithProfile()and ensure profiles exist. -
Slot visibility: All document slots show even with zero comments (by design, Issue 1 fix). The backend fetches ALL
ApplicationDocumentDefinitionrows per application. If slots are missing, checkfindByApplicationIdIninApplicationDocumentDefinitionRepository. -
ApplicationRow collapsed by default:
ApplicationRowusesuseState(false)— collapsed on first render. Do NOT change totrue; the rich collapsed preview (applicants, status, docs, preview) is designed to be the default view. -
Applicants sorted PRIMARY first: In
buildAppSummary()the applicants list is sorted byApplicantTypeordinal. On the frontend, the applicants array arrives already sorted — do NOT re-sort in the UI. -
onSelectThreadwiring:MessagesPage(src/features/messages/index.tsx) passesonSelectThread={setSelectedThread}toThreadDetail. If you add a new entry point toThreadDetail, wire this prop through or breadcrumb navigation (clicking a category in the thread header) will be broken. -
Applicant type color scheme is shared: Both
thread-tree.tsx(collapsed ApplicationRow) andthread-detail.tsx(ThreadHeader) define the sameAPPLICANT_TYPE_STYLEmap independently. If you add a new applicant type, update both maps. The scheme:PRIMARY=indigo,SECONDARY=teal,TERTIARY=violet,FOURTH=amber,FIFTH=rose.