Skip to content

feat(mothership): server-persisted unread task indicators via SSE#3549

Merged
waleedlatif1 merged 10 commits intofeat/mothership-copilotfrom
feat/unread-task-indicators
Mar 13, 2026
Merged

feat(mothership): server-persisted unread task indicators via SSE#3549
waleedlatif1 merged 10 commits intofeat/mothership-copilotfrom
feat/unread-task-indicators

Conversation

@waleedlatif1
Copy link
Collaborator

@waleedlatif1 waleedlatif1 commented Mar 12, 2026

Summary

  • Server-persisted unread semantics via lastSeenAt DB column — survives reload, multi-tab, multi-device
  • Real-time SSE push via Redis pub/sub (with local EventEmitter fallback) replaces client-side polling
  • Dot overlay UI on Blimp icon: pulsing --brand-tertiary-2 = running, solid --brand-tertiary = unread, no dot = idle
  • Security fix: added userId ownership check to chat delete endpoint
  • Bug fixes: updatedAt now bumped on stream completion; lastSeenAt set on rename to prevent false-positive unread

Changes

Schema

  • Add lastSeenAt column to copilotChats table + migration

Server

  • lib/copilot/task-events.ts — Redis/local pub/sub singleton for task status events
  • api/mothership/events — SSE endpoint with workspace filtering, heartbeat, connection tracking
  • api/mothership/chats/read — Mark-read endpoint (sets lastSeenAt)
  • Publish SSE events from chat, rename, delete, auto-title, and chat creation handlers

Client

  • hooks/use-task-events.ts — SSE subscription hook that invalidates task queries on events
  • hooks/queries/tasks.tsisActive/isUnread derived state, useMarkTaskRead optimistic mutation
  • Sidebar: TaskStatusIcon with dot overlay, isCurrentRoute suppresses dots when viewing
  • Home: mark-read on mount + stream completion

Test plan

  • Create a task, send a prompt, navigate away → pulsing green dot in sidebar
  • Wait for completion → solid green dot appears (SSE push)
  • Reload page → green dot persists (server-persisted)
  • Click the task → dot disappears (mark-read)
  • Rename a task → no false-positive unread dot
  • Watch a task complete while on its page → no dot in sidebar
  • Network tab: single SSE connection, no polling requests
  • Delete a task → SSE event fires, list updates

@vercel
Copy link

vercel bot commented Mar 12, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Mar 13, 2026 1:09am

Request Review

@cursor
Copy link

cursor bot commented Mar 12, 2026

PR Summary

Medium Risk
Introduces a new DB column plus Redis-backed pub/sub and an SSE endpoint that drives task list state in real time; issues could affect task list freshness and connection stability across workspaces. Also touches chat lifecycle/update paths (create/rename/delete/stream completion), which could cause subtle state regressions if event or timestamp updates are incorrect.

Overview
Adds server-persisted unread/active task indicators for mothership chats by introducing copilot_chats.last_seen_at and new logic to derive isActive/isUnread in the task list.

Introduces a task status event pipeline: a new Redis (or local fallback) taskPubSub, a workspace-scoped SSE endpoint at GET /api/mothership/events, and client subscription via useTaskEvents that invalidates task queries on task_status events.

Updates chat/task mutations and streaming paths to publish status events (created, started, completed, renamed, deleted) and to keep timestamps consistent (updatedAt bumped on completion; lastSeenAt set on create/rename/auto-title). Adds a POST /api/mothership/chats/read endpoint plus client-side useMarkTaskRead to clear unread state when viewing/completing a task, and tightens the delete endpoint to enforce user ownership.

Written by Cursor Bugbot for commit 59bae28. Configure here.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 12, 2026

Greptile Summary

This PR introduces server-persisted unread task indicators via lastSeenAt DB column and real-time SSE push using a Redis pub/sub adapter (with a local EventEmitter fallback). Together, these replace any client-side polling and allow unread dots to survive reloads and sync across tabs/devices. A security fix adds userId ownership checks to the chat delete endpoint, and several correctness fixes (updatedAt bumped on stream completion, lastSeenAt set on rename/auto-title) prevent false-positive unread states.

Key changes:

  • New task:status_changed Redis/local pub/sub singleton (task-events.ts) broadcasts task lifecycle events to all SSE connections on a worker
  • New SSE endpoint (/api/mothership/events) authenticates via session cookie, verifies workspace access, and streams task_status events with 30s heartbeat
  • New mark-read endpoint (/api/mothership/chats/read) persists lastSeenAt with user ownership check
  • home.tsx uses two effects with wasSendingRef to call markRead on navigation and stream completion without double-firing
  • Migration backfills last_seen_at from updated_at on all existing rows to avoid a mass false-positive unread on deploy
  • taskPubSub is exported as TaskPubSubAdapter but is actually null on the client side — the type should be TaskPubSubAdapter | null to enforce optional-chaining at the type level (all current callers already use ?. but future callers won't be warned)
  • The mark-read endpoint returns { success: true } even when no row was matched by the ownership WHERE clause (no .returning() check), unlike the delete/rename endpoints which return 404 — a minor inconsistency
  • The SidebarTaskItem click handler was changed from calling clearTaskSelection() on regular clicks to calling selectTaskOnly(taskId), which correctly sets the range-select anchor but can leave stale multi-select state when tasks are navigated to programmatically rather than via a sidebar click

Confidence Score: 4/5

  • Safe to merge — the core SSE/unread mechanics are correct and previously identified issues have been addressed; remaining notes are minor type safety and consistency concerns.
  • The critical issues from previous review rounds (backfill migration, false-positive on creation, ownership check on delete, updatedAt bump, wasSendingRef reset) have all been fixed. The implementation is architecturally sound: Redis pub/sub with local fallback, idempotent cleanup, correct isUnread derivation, and optimistic updates with rollback. The open items are a type lie on taskPubSub (all callers are already defensive), a silent no-op on mark-read for missing rows (benign), and a minor behavioral change in sidebar click handling that doesn't break existing functionality.
  • apps/sim/lib/copilot/task-events.ts (type safety on export) and apps/sim/app/api/mothership/chats/read/route.ts (missing .returning() check).

Important Files Changed

Filename Overview
apps/sim/lib/copilot/task-events.ts New Redis/local EventEmitter pub/sub adapter for broadcasting task status events; solid implementation with proper disposal and error handling, but exports taskPubSub with an incorrect non-nullable type despite being null in browser contexts.
apps/sim/app/api/mothership/events/route.ts New SSE endpoint with proper auth (session + workspace permissions), heartbeat, connection tracking, and idempotent cleanup via cleaned flag; well-structured and correct.
apps/sim/app/api/mothership/chats/read/route.ts New mark-read endpoint with correct userId ownership check, but uses no .returning() so silently no-ops when the chat doesn't exist or isn't owned — unlike the delete/rename endpoints which return 404 for missing rows.
apps/sim/hooks/queries/tasks.ts Adds isActive/isUnread derived state via mapTask and a new useMarkTaskRead mutation with correct optimistic update and rollback; implementation is clean and follows existing patterns.
apps/sim/hooks/use-task-events.ts Clean SSE subscription hook with workspaceId URL-encoding, proper cleanup on unmount, and simple invalidateQueries invalidation strategy.
apps/sim/app/workspace/[workspaceId]/home/home.tsx Adds useMarkTaskRead integration with two effects — one to mark-read on task navigation (resetting wasSendingRef) and one to mark-read on stream completion; the wasSendingRef reset correctly prevents redundant calls when switching tasks mid-stream.
apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx Adds TaskStatusIcon with dot overlay and useTaskEvents subscription; refactors SidebarTaskItem to accept status and isCurrentRoute props, but changes regular-click behavior from clearTaskSelection to selectTaskOnly, which can leave stale multi-select state during programmatic navigation.
packages/db/migrations/0171_yielding_venom.sql Adds nullable last_seen_at column and immediately backfills it from updated_at, preventing all pre-existing completed chats from appearing as unread after migration.
apps/sim/app/api/copilot/chat/delete/route.ts Security fix adding userId ownership check via and(eq(...userId)) with 404 on miss; also publishes deleted SSE event for real-time sidebar updates.
apps/sim/app/api/mothership/chat/route.ts Adds started and completed SSE event publishing around stream lifecycle; also bumps updatedAt on stream completion so isUnread logic fires correctly.
apps/sim/lib/copilot/chat-streaming.ts Auto-title path now sets both updatedAt and lastSeenAt to the same timestamp, preventing false-positive unread dots from title updates; publishes renamed SSE event as intended.

Sequence Diagram

sequenceDiagram
    participant Browser as Browser (Sidebar)
    participant SSE as /api/mothership/events (SSE)
    participant ChatAPI as /api/mothership/chat
    participant Redis as Redis Pub/Sub
    participant ReadAPI as /api/mothership/chats/read
    participant DB as PostgreSQL

    Browser->>SSE: GET /api/mothership/events?workspaceId=...
    SSE->>DB: check session + workspace permissions
    SSE-->>Browser: 200 text/event-stream (connected)
    Note over SSE,Redis: taskPubSub.onStatusChanged registered

    Browser->>ChatAPI: POST /api/mothership/chat (send message)
    ChatAPI->>DB: update conversationId (set stream ID)
    ChatAPI->>Redis: publishStatusChanged('started')
    Redis-->>SSE: task:status_changed event
    SSE-->>Browser: event: task_status (type: started)
    Browser->>Browser: invalidateQueries → isActive=true, dot pulses

    ChatAPI-->>Browser: SSE stream chunks (content)
    Note over Browser: isSending=true, wasSendingRef=true

    ChatAPI->>DB: update messages + updatedAt, clear conversationId
    ChatAPI->>Redis: publishStatusChanged('completed')
    Redis-->>SSE: task:status_changed event
    SSE-->>Browser: event: task_status (type: completed)
    Browser->>Browser: invalidateQueries → isUnread=true (updatedAt > lastSeenAt)

    Note over Browser: isSending→false (stream ended before SSE event)
    Browser->>ReadAPI: POST /api/mothership/chats/read (chatId)
    Note over Browser: onMutate: optimistic isUnread=false
    ReadAPI->>DB: UPDATE lastSeenAt=NOW()
    ReadAPI-->>Browser: { success: true }
    Browser->>Browser: onSettled: invalidateQueries → isUnread=false confirmed
Loading

Comments Outside Diff (3)

  1. apps/sim/lib/copilot/task-events.ts, line 167-168 (link)

    taskPubSub type incorrectly declared as non-nullable

    The exported constant is typed as TaskPubSubAdapter but evaluates to null in browser environments (typeof window !== 'undefined'). The null as unknown as TaskPubSubAdapter double cast is a type lie — TypeScript will not require callers to guard against null.

    All current callers happen to use taskPubSub?.publishStatusChanged(...) with optional chaining, so this works in practice. But future callers could write taskPubSub.onStatusChanged(...) without ?., pass type-checking, and then throw at runtime when this module is imported on the client side.

    Typing it correctly as TaskPubSubAdapter | null would make TypeScript enforce the guard everywhere:

  2. apps/sim/app/api/mothership/chats/read/route.ts, line 32-35 (link)

    Silent no-op when chat not found or not owned

    The db.update(...) call uses no .returning(), so it's impossible to tell whether any row was actually matched by the ownership WHERE clause. If the chatId doesn't exist or belongs to a different user, the query silently updates 0 rows and the handler still returns { success: true }. The delete and rename endpoints both use .returning() to detect this case and return a 404.

    For a mark-read operation this is benign (the caller doesn't need to retry), but it means callers — including optimistic-update logic in useMarkTaskRead — can't distinguish a genuine success from a silent miss. Adding a .returning() check (consistent with the other mutation endpoints) would surface the discrepancy:

    const [updated] = await db
      .update(copilotChats)
      .set({ lastSeenAt: new Date() })
      .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
      .returning({ id: copilotChats.id })
    
    if (!updated) {
      return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
    }
  3. apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx, line 128-135 (link)

    Regular task click now adds to multi-select state instead of clearing it

    The previous onClick called clearTaskSelection() on a regular (no-modifier) click, so navigating away from a task via the address bar or any non-sidebar mechanism left the sidebar with no task highlighted. The new code unconditionally calls onMultiSelectClickhandleTaskClickselectTaskOnly(taskId), which adds the task to selectedTasks and sets lastSelectedTaskId.

    As a result, if a user navigates to a task page via a direct URL change (browser bar, router.push, etc.), the previously visited task can remain highlighted in selectedTasks (since clearTaskSelection is no longer called on navigation). Workflow clicks do call selectWorkflowOnly which clears selectedTasks, so typical in-sidebar navigation is unaffected. However, programmatic navigation (e.g., router.push from a button action) won't clear the stale selection.

    The positive side of this change is that lastSelectedTaskId is now correctly set on regular clicks, making shift-click range selection work properly after a regular navigation. If that's the intent, a comment noting the deliberate choice would clarify the trade-off for future maintainers.

Last reviewed commit: 1371e61

Replace fragile client-side polling + timer-based green flash with
server-persisted lastSeenAt semantics, real-time SSE push via Redis
pub/sub, and dot overlay UI on the Blimp icon.

- Add lastSeenAt column to copilotChats for server-persisted read state
- Add Redis/local pub/sub singleton for task status events (started,
  completed, created, deleted, renamed)
- Add SSE endpoint (GET /api/mothership/events) with heartbeat and
  workspace-scoped filtering
- Add mark-read endpoint (POST /api/mothership/chats/read)
- Publish SSE events from chat, rename, delete, and auto-title handlers
- Add useTaskEvents hook for client-side SSE subscription
- Add useMarkTaskRead mutation with optimistic update
- Replace timer logic in sidebar with TaskStatus state machine
  (running/unread/idle) and dot overlay using brand color variables
- Mark tasks read on mount and stream completion in home page
- Fix security: add userId check to delete WHERE clause
- Fix: bump updatedAt on stream completion
- Fix: set lastSeenAt on rename to prevent false-positive unread

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Return 404 when delete finds no matching chat (was silent no-op)
- Move log after ownership check so it only fires on actual deletion
- Publish completed SSE event from stop route so sidebar dot clears on abort

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@waleedlatif1
Copy link
Collaborator Author

@greptile

@waleedlatif1
Copy link
Collaborator Author

@cursor review

Existing rows would have last_seen_at = NULL after migration, causing
all past completed tasks to show as unread. Backfill sets last_seen_at
to updated_at for all existing rows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@waleedlatif1
Copy link
Collaborator Author

@greptile

@waleedlatif1
Copy link
Collaborator Author

@cursor review

…navigation

- Pass updatedAt explicitly alongside lastSeenAt on chat creation so
  both use the same JS timestamp (DB defaultNow() ran later, causing
  updatedAt > lastSeenAt → false unread)
- Reset wasSendingRef when chatId changes to prevent a stale true
  from task A triggering a redundant markRead on task B

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@waleedlatif1
Copy link
Collaborator Author

@greptile

@waleedlatif1
Copy link
Collaborator Author

@cursor review

… SSE URL

Expose resolvedChatId from useChat so home.tsx can mark-read even when
chatId prop stays undefined after replaceState URL update. Also
URL-encode workspaceId in EventSource URL as a defensive measure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@waleedlatif1
Copy link
Collaborator Author

@greptile @cursor review — addressed both comments: mark-read now uses resolvedChatId from useChat state (fixes inline-created tasks), and workspaceId is URL-encoded in EventSource URL.

…andling

Auto-focus the textarea when the initial home view renders. Also fix
sidebar task click to always call onMultiSelectClick so selection state
stays consistent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@waleedlatif1
Copy link
Collaborator Author

@cursor review

@waleedlatif1
Copy link
Collaborator Author

@greptile

Auto-title now sets both updatedAt and lastSeenAt (matching the rename
route pattern) to prevent false-positive unread dots. Also move the
'started' SSE event inside the if(updated) guard so it only fires when
the DB update actually matched a row.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@waleedlatif1
Copy link
Collaborator Author

waleedlatif1 commented Mar 13, 2026

@greptile fixed auto-title lastSeenAt gap and moved started event inside DB update guard. Normal click selection comment was a false positive (selectTaskOnly already clears). SSE payload optimization acknowledged for future work.

@waleedlatif1
Copy link
Collaborator Author

@cursor review

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

- Extract createPubSubChannel factory (lib/events/pubsub.ts) to eliminate
  duplicated Redis/EventEmitter boilerplate between task and MCP pub/sub
- Extract createWorkspaceSSE factory (lib/events/sse-endpoint.ts) to share
  auth, heartbeat, and cleanup logic across SSE endpoints
- Fix auto-title race suppressing unread status by removing updatedAt/lastSeenAt
  from title-only DB update
- Fix wheel event listener leak in ResourceTabs (RefCallback cleanup was silently
  discarded)
- Fix getFullSelection() missing taskIds (inconsistent with hasAnySelection)
- Deduplicate SSE_RESPONSE_HEADERS to spread from shared SSE_HEADERS
- Hoist isSttAvailable to module-level constant to avoid per-render IIFE

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@waleedlatif1 waleedlatif1 merged commit 7bd03cf into feat/mothership-copilot Mar 13, 2026
3 checks passed
@waleedlatif1 waleedlatif1 deleted the feat/unread-task-indicators branch March 13, 2026 01:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant