Skip to main content

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 first
  • AppDocumentStatusSummaryDto documentsStatus — nullable, null if no documents
  • boolean hasAttachments — true if any comment content references a file

WorkspaceCommentService.buildAppSummary() enrichment:

  1. applicants — reuses the existing applicantRepository.findByApplicationIdInWithProfile() batch fetch; maps Applicant.type + profile.getFullName() + profile email; sorted by ApplicantType ordinal (PRIMARY first)
  2. documentsStatus — queries ApplicationDocumentRepository for status counts grouped by applicationId; maps to AppDocumentStatusSummaryDto
  3. hasAttachments — checks if any comment content string contains data-file-refs (simple String.contains check)

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 ALL ApplicationDocumentDefinition records for the application — even those with zero comments (so the left tree always shows all slots)
  • applicantNames resolved via batch join on Applicant.profile.fullName (kept for backwards compat)
  • applicants[] — enriched list with name, type, sorted PRIMARY first
  • documentsStatus{ total, approved, uploaded, pending, rejected } counts
  • hasAttachments — 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 workspaceCommentSummary and workspaceUnreadCommentCount query keys

Hooks (use-workspace-comments.ts)

HookPurpose
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[] — from useWorkspaceCommentSummary
  • filter: MessageFilter — 'all' | 'unread' | 'needsReply' | 'internal' | 'external'
  • search: string — filters by application title
  • flatMode?: boolean — when true (per-app mode) renders category rows directly without the application-level collapsible header
  • selectedThread: SelectedThread | null
  • onSelectCategory / onSelectSlot — set SelectedThread state in parent

Category order: APPLICATION → DOCUMENTS → DECISION → APPLICANTS

Click behaviour:

  • Click category rowslotId = 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 when totalCount > 0
  • Orange dot when needsReply = true
  • Blue unread badge when unreadCount > 0

ApplicationRow — Collapsed State (Rich Preview)

Applications are collapsed by default (useState(false)). The collapsed row shows:

  1. Application title
  2. Applicant badges — sorted PRIMARY first, each shows colored initials avatar + type short label. Color scheme: PRIMARY=indigo, SECONDARY=teal, TERTIARY=violet, FOURTH=amber, FIFTH=rose
  3. Application status chip
  4. Documents status — compact icon+count chips: ✓ approved · ↑ uploaded · ⏳ pending · ✗ rejected
  5. Latest message preview — finds the category with max latestCommentAt, shows "AuthorName: preview..." truncated
  6. Paperclip icon — if hasAttachments = true
  7. 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 calls onSelectThread({ ...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:
    • Avatar with colored initials (bg-indigo-50 text-indigo-700 for 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 localStorage key ams.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)

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 230px offset from view2.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:

  1. WorkspaceCommentService batch-fetches all slots via ApplicationDocumentDefinitionRepository.findByApplicationIdIn(appIds)
  2. Slots are sorted by sortOrder then merged with comment data
  3. 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:

KeyUsage
messages.titlePage header
messages.loadingLoading state in both panels
messages.emptyLeft tree when no results
messages.selectThreadRight panel placeholder
messages.newMessages"── New Messages ──" divider
messages.openInAppHeader link to application page
messages.needsReplyOrange banner in thread header
messages.search.placeholderFilter bar search input
messages.filter.all/unread/needsReply/internal/externalLeft panel filter tabs
messages.category.application/documents/decision/applicantsCategory row labels

Common Pitfalls

  1. Sort order: Server returns DESC (newest first). To show oldest-first, you MUST reverse. Using sortAsc ? filteredComments : reverse will show newest-first when sortAsc=true — that is WRONG. The correct form is sortAsc ? reverse : filteredComments.

  2. Height offset: Use lg:h-[calc(100svh-112px)] for this top-level page, not 230px (that's for app detail context with sub-headers).

  3. TypedDocumentString: All queries use new TypedDocumentString(...) directly. Do NOT use the graphql() codegen function for this feature.

  4. "New Messages" divider guard: Only show when 0 < unreadCount < totalCount. If unreadCount === totalCount (user never read the thread), show nothing — otherwise every comment appears "new".

  5. Applicant names empty: If applicantNames is [], the backend's WorkspaceCommentService batch fetch may be failing. Check ApplicantRepository.findByApplicationIdInWithProfile() and ensure profiles exist.

  6. Slot visibility: All document slots show even with zero comments (by design, Issue 1 fix). The backend fetches ALL ApplicationDocumentDefinition rows per application. If slots are missing, check findByApplicationIdIn in ApplicationDocumentDefinitionRepository.

  7. ApplicationRow collapsed by default: ApplicationRow uses useState(false) — collapsed on first render. Do NOT change to true; the rich collapsed preview (applicants, status, docs, preview) is designed to be the default view.

  8. Applicants sorted PRIMARY first: In buildAppSummary() the applicants list is sorted by ApplicantType ordinal. On the frontend, the applicants array arrives already sorted — do NOT re-sort in the UI.

  9. onSelectThread wiring: MessagesPage (src/features/messages/index.tsx) passes onSelectThread={setSelectedThread} to ThreadDetail. If you add a new entry point to ThreadDetail, wire this prop through or breadcrumb navigation (clicking a category in the thread header) will be broken.

  10. Applicant type color scheme is shared: Both thread-tree.tsx (collapsed ApplicationRow) and thread-detail.tsx (ThreadHeader) define the same APPLICANT_TYPE_STYLE map independently. If you add a new applicant type, update both maps. The scheme: PRIMARY=indigo, SECONDARY=teal, TERTIARY=violet, FOURTH=amber, FIFTH=rose.