From a54af47c9744806a3036c371a77a4463206b44f7 Mon Sep 17 00:00:00 2001 From: Rory O'Keeffe Date: Mon, 14 Sep 2026 12:23:02 +0100 Subject: [PATCH 1/5] feat(inbox): show unread mentions and add --mentions filter. `threads/get_unread` already returns a `directMention` flag per unread thread, but the CLI reduced the response to a Set of IDs and dropped it. Keep the full entry, expose it as `hasUnreadMention` on each inbox thread (JSON and human output), add a `--mentions` filter, and sort mention threads first within each channel. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XYtXq3VLuv34zPKicow2jg --- README.md | 1 + docs/SPEC.md | 4 +- skills/comms-cli/SKILL.md | 2 + src/commands/inbox.test.ts | 105 +++++++++++++++++++++++++++++++++++++ src/commands/inbox.ts | 40 +++++++++----- src/lib/output.ts | 1 + src/lib/skills/content.ts | 2 + src/lib/threads.ts | 16 ++++-- 8 files changed, 154 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index f0861b4..141e53b 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ tdc auth refresh-token view # print the stored OAuth refresh token ```bash tdc inbox # inbox threads tdc inbox --unread # unread threads only +tdc inbox --mentions # unread threads where you were mentioned tdc mentions # content mentioning you tdc mentions --since 2026-04-01 --all --json tdc thread view # view thread with comments diff --git a/docs/SPEC.md b/docs/SPEC.md index 0312a60..ad62615 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -135,6 +135,7 @@ Arguments: Options: - `--unread` - Only show unread threads +- `--mentions` - Only show unread threads where you were mentioned (implies `--unread`) - `--since ` - Filter by date (ISO format) - `--until ` - Filter by date - `--limit ` - Max items (default: 50) @@ -142,7 +143,7 @@ Options: Output format (human-readable): -- Title, channel name, timestamp (relative), unread indicator +- Title, channel name, timestamp (relative), unread indicator, mention indicator (`@` / `(mention)`) - URL on second line for each entry - Content truncated in list view @@ -490,6 +491,7 @@ tdc workspace use "My Team" # View inbox tdc inbox tdc inbox --unread +tdc inbox --mentions # View a thread tdc thread view id:CbT8n2Kp4Qx6Rz9Lm3Va diff --git a/skills/comms-cli/SKILL.md b/skills/comms-cli/SKILL.md index 7514054..7bb40de 100644 --- a/skills/comms-cli/SKILL.md +++ b/skills/comms-cli/SKILL.md @@ -75,6 +75,7 @@ All target command flags pass through (e.g. `--json`, `--raw`, `--full`). ```bash tdc inbox # Show inbox threads tdc inbox --unread # Only unread threads +tdc inbox --mentions # Only unread threads where you were mentioned (implies --unread) tdc inbox --archive-filter all # Show active + done threads tdc inbox --archive-filter archived # Show only done threads tdc inbox --channel # Filter by channel name (fuzzy) @@ -461,6 +462,7 @@ tdc view https://comms.todoist.com/a/1585/msg/CbV8n2Kp4Qx6Rz9Lm3Va/m/CbS8n2Kp4Qx **Check inbox and respond:** ```bash tdc inbox --unread --json +tdc inbox --mentions --json # Unread threads with an unread @mention of you tdc thread view --unread tdc thread reply "Thanks, I'll look into this." tdc thread done --yes diff --git a/src/commands/inbox.test.ts b/src/commands/inbox.test.ts index 7b36b37..b7cee3d 100644 --- a/src/commands/inbox.test.ts +++ b/src/commands/inbox.test.ts @@ -204,3 +204,108 @@ describe('inbox API errors', () => { ).rejects.toThrow('limit must be <= 500') }) }) + +describe('inbox unread mentions', () => { + const threads = [ + { + id: 'thread-read', + channelId: 'CH1', + title: 'Read thread', + posted: '2026-05-03T00:00:00Z', + url: 'https://example.test/thread-read', + }, + { + id: 'thread-unread', + channelId: 'CH1', + title: 'Plain unread', + posted: '2026-05-02T00:00:00Z', + url: 'https://example.test/thread-unread', + }, + { + id: 'thread-mention', + channelId: 'CH1', + title: 'Mentioned thread', + posted: '2026-05-01T00:00:00Z', + url: 'https://example.test/thread-mention', + }, + ] + const unreadData = [ + { threadId: 'thread-unread', channelId: 'CH1', objIndex: 3, directMention: false }, + { threadId: 'thread-mention', channelId: 'CH1', objIndex: 5, directMention: true }, + ] + let logSpy: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) + mockClient({ + inboxThreads: threads, + unreadData, + getChannel: vi.fn().mockResolvedValue({ id: 'CH1', name: 'engineering' }), + }) + logSpy = captureConsole('log') + }) + + function parsedJsonOutput(): Array> { + expect(logSpy).toHaveBeenCalledTimes(1) + return JSON.parse(logSpy.mock.calls[0]?.[0] as string) + } + + it('derives hasUnreadMention from the getUnread directMention flag', async () => { + await createProgram().parseAsync(['node', 'tdc', 'inbox', '--json']) + + const byId = new Map(parsedJsonOutput().map((t) => [t.id, t])) + expect(byId.get('thread-read')).toMatchObject({ isUnread: false, hasUnreadMention: false }) + expect(byId.get('thread-unread')).toMatchObject({ + isUnread: true, + hasUnreadMention: false, + }) + expect(byId.get('thread-mention')).toMatchObject({ + isUnread: true, + hasUnreadMention: true, + }) + }) + + it('--mentions keeps only unread threads with a direct mention', async () => { + await createProgram().parseAsync(['node', 'tdc', 'inbox', '--mentions', '--json']) + + expect(parsedJsonOutput().map((t) => t.id)).toEqual(['thread-mention']) + }) + + it('--mentions takes precedence over --unread', async () => { + await createProgram().parseAsync([ + 'node', + 'tdc', + 'inbox', + '--unread', + '--mentions', + '--json', + ]) + + expect(parsedJsonOutput().map((t) => t.id)).toEqual(['thread-mention']) + }) + + it('sorts mention threads before newer plain-unread threads within a channel', async () => { + await createProgram().parseAsync(['node', 'tdc', 'inbox', '--json']) + + expect(parsedJsonOutput().map((t) => t.id)).toEqual([ + 'thread-mention', + 'thread-unread', + 'thread-read', + ]) + }) + + it('shows a mention marker next to the unread badge in human output', async () => { + vi.stubEnv('TDC_ACCESSIBLE', '0') + try { + await createProgram().parseAsync(['node', 'tdc', 'inbox']) + } finally { + vi.unstubAllEnvs() + } + + const lines = logSpy.mock.calls.flat() as string[] + expect(lines).toContain(' Mentioned thread * @') + expect(lines).toContain(' Plain unread *') + expect(lines).toContain(' Read thread') + }) +}) diff --git a/src/commands/inbox.ts b/src/commands/inbox.ts index b166bb4..3a8103e 100644 --- a/src/commands/inbox.ts +++ b/src/commands/inbox.ts @@ -11,12 +11,13 @@ import { toDate, type PaginatedViewOptions } from '../lib/options.js' import { colors, formatJson, formatNdjson } from '../lib/output.js' import { getPublicChannelIds } from '../lib/public-channels.js' import { resolveWorkspaceRef } from '../lib/refs.js' -import { fetchUnreadThreadIds } from '../lib/threads.js' +import { fetchUnreadThreads } from '../lib/threads.js' type InboxOptions = PaginatedViewOptions & { workspace?: string channel?: string unread?: boolean + mentions?: boolean archiveFilter?: ArchiveFilter } @@ -42,7 +43,7 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions const client = await getCommsClient() const limit = options.limit ? parseInt(options.limit, 10) : 50 - const [threads, unreadThreadIds] = await Promise.all([ + const [threads, unreadThreads] = await Promise.all([ client.inbox.getInbox({ workspaceId, newerThan: toDate(options.since), @@ -50,15 +51,21 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions limit, archiveFilter: options.archiveFilter ?? 'active', }), - fetchUnreadThreadIds(client, workspaceId), + fetchUnreadThreads(client, workspaceId), ]) - let inboxThreads = threads.map((t) => ({ - ...t, - isUnread: unreadThreadIds.has(t.id), - })) + let inboxThreads = threads.map((t) => { + const unread = unreadThreads.get(t.id) + return { + ...t, + isUnread: unread !== undefined, + hasUnreadMention: unread?.directMention === true, + } + }) - if (options.unread) { + if (options.mentions) { + inboxThreads = inboxThreads.filter((t) => t.hasUnreadMention) + } else if (options.unread) { inboxThreads = inboxThreads.filter((t) => t.isUnread) } @@ -104,7 +111,7 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions } } - // Group by channel, unreads first within each channel, then sort by date (newest first) + // Group by channel; within each channel: unread mentions, then other unreads, then reads, each newest first const groupedByChannel = new Map() for (const thread of inboxThreads) { const group = groupedByChannel.get(thread.channelId) || [] @@ -117,9 +124,10 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions const sortedChannelGroups: typeof inboxThreads = [] for (const [, threads] of groupedByChannel) { - const unreads = threads.filter((t) => t.isUnread).sort(sortByDate) + const mentions = threads.filter((t) => t.hasUnreadMention).sort(sortByDate) + const unreads = threads.filter((t) => t.isUnread && !t.hasUnreadMention).sort(sortByDate) const reads = threads.filter((t) => !t.isUnread).sort(sortByDate) - sortedChannelGroups.push(...unreads, ...reads) + sortedChannelGroups.push(...mentions, ...unreads, ...reads) } if (outputMode === 'ids-only') { @@ -158,8 +166,11 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions const title = thread.isUnread ? chalk.bold(thread.title) : thread.title const time = colors.timestamp(formatRelativeDate(thread.posted)) const unreadBadge = thread.isUnread ? chalk.blue(isAccessible() ? ' (unread)' : ' *') : '' + const mentionBadge = thread.hasUnreadMention + ? chalk.yellow(isAccessible() ? ' (mention)' : ' @') + : '' - console.log(` ${title}${unreadBadge}`) + console.log(` ${title}${unreadBadge}${mentionBadge}`) console.log(` ${time} ${colors.timestamp(`id:${thread.id}`)}`) console.log(` ${colors.url(thread.url)}`) console.log('') @@ -173,6 +184,10 @@ export function registerInboxCommand(program: Command): void { .option('--workspace ', 'Workspace ID or name') .option('--channel ', 'Filter by channel name (fuzzy match)') .option('--unread', 'Only show unread threads') + .option( + '--mentions', + 'Only show unread threads where you were mentioned (implies --unread)', + ) .addOption( withCaseInsensitiveChoices( new Option( @@ -195,6 +210,7 @@ export function registerInboxCommand(program: Command): void { Examples: tdc inbox tdc inbox --unread + tdc inbox --mentions tdc inbox --archive-filter all tdc inbox --archive-filter archived tdc inbox --channel engineering --since 2025-01-01 diff --git a/src/lib/output.ts b/src/lib/output.ts index 966b515..21b6b64 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -24,6 +24,7 @@ const THREAD_ESSENTIAL_FIELDS = [ 'commentCount', 'isArchived', 'isUnread', + 'hasUnreadMention', 'url', 'reactions', ] as const diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index ef49151..7c36720 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -79,6 +79,7 @@ All target command flags pass through (e.g. \`--json\`, \`--raw\`, \`--full\`). \`\`\`bash tdc inbox # Show inbox threads tdc inbox --unread # Only unread threads +tdc inbox --mentions # Only unread threads where you were mentioned (implies --unread) tdc inbox --archive-filter all # Show active + done threads tdc inbox --archive-filter archived # Show only done threads tdc inbox --channel # Filter by channel name (fuzzy) @@ -465,6 +466,7 @@ tdc view https://comms.todoist.com/a/1585/msg/CbV8n2Kp4Qx6Rz9Lm3Va/m/CbS8n2Kp4Qx **Check inbox and respond:** \`\`\`bash tdc inbox --unread --json +tdc inbox --mentions --json # Unread threads with an unread @mention of you tdc thread view --unread tdc thread reply "Thanks, I'll look into this." tdc thread done --yes diff --git a/src/lib/threads.ts b/src/lib/threads.ts index 4d41d2d..4dbfe2e 100644 --- a/src/lib/threads.ts +++ b/src/lib/threads.ts @@ -1,10 +1,18 @@ -import type { CommsApi } from '@doist/comms-sdk' +import type { CommsApi, UnreadThread } from '@doist/comms-sdk' + +/** Normalises the SDK's `{ data, version }` unread response into a Map keyed by thread ID for O(1) joins. */ +export async function fetchUnreadThreads( + client: CommsApi, + workspaceId: number, +): Promise> { + const unread = await client.threads.getUnread(workspaceId) + return new Map(unread.data.map((u) => [u.threadId, u])) +} -/** Normalises the SDK's `{ data, version }` unread response into a Set for O(1) joins. */ export async function fetchUnreadThreadIds( client: CommsApi, workspaceId: number, ): Promise> { - const unread = await client.threads.getUnread(workspaceId) - return new Set(unread.data.map((u) => u.threadId)) + const unread = await fetchUnreadThreads(client, workspaceId) + return new Set(unread.keys()) } From f1dee154551a13c7f5fab03b578cfb11dccbef71 Mon Sep 17 00:00:00 2001 From: Rory O'Keeffe Date: Tue, 15 Sep 2026 16:40:46 +0100 Subject: [PATCH 2/5] refactor(inbox): compose filters and sort by tier. --mentions and --unread now compose instead of one overriding the other. The three-pass filter/sort becomes one keyed sort. The last Set-based caller moves onto the shared Map helper and the wrapper goes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XYtXq3VLuv34zPKicow2jg --- src/commands/channel/threads.ts | 14 +++++++------- src/commands/inbox.test.ts | 13 ------------- src/commands/inbox.ts | 13 ++++++------- src/lib/threads.ts | 10 +--------- 4 files changed, 14 insertions(+), 36 deletions(-) diff --git a/src/commands/channel/threads.ts b/src/commands/channel/threads.ts index 8570e67..017d809 100644 --- a/src/commands/channel/threads.ts +++ b/src/commands/channel/threads.ts @@ -1,5 +1,5 @@ import { outputIds, resolveOutputMode } from '@doist/cli-core' -import type { ArchiveFilter, Thread } from '@doist/comms-sdk' +import type { ArchiveFilter, Thread, UnreadThread } from '@doist/comms-sdk' import chalk from 'chalk' import { getCommsClient, getCurrentWorkspaceId } from '../../lib/api.js' import { formatRelativeDate } from '../../lib/dates.js' @@ -14,7 +14,7 @@ import { } from '../../lib/output.js' import { assertChannelIsPublic } from '../../lib/public-channels.js' import { resolveChannelRef, resolveWorkspaceRef } from '../../lib/refs.js' -import { fetchUnreadThreadIds } from '../../lib/threads.js' +import { fetchUnreadThreads } from '../../lib/threads.js' import { decodeCursor, encodeCursor } from './helpers.js' type ChannelThreadsOptions = PaginatedViewOptions & { @@ -80,21 +80,21 @@ export async function showChannelThreads( const client = await getCommsClient() const needsUnreadData = outputMode !== 'ids-only' || options.unread - const [threadsData, unreadThreadIds] = await Promise.all([ + const [threadsData, unreadThreads] = await Promise.all([ client.threads.getThreads( archived === undefined ? { workspaceId, channelId: channel.id } : { workspaceId, channelId: channel.id, archived }, ), needsUnreadData - ? fetchUnreadThreadIds(client, workspaceId) - : Promise.resolve(new Set()), + ? fetchUnreadThreads(client, workspaceId) + : Promise.resolve(new Map()), ]) let threads = threadsData if (options.unread) { - threads = threads.filter((thread) => unreadThreadIds.has(thread.id)) + threads = threads.filter((thread) => unreadThreads.has(thread.id)) } if (sinceTs !== undefined) { @@ -124,7 +124,7 @@ export async function showChannelThreads( const decoratedPage: DecoratedThread[] = page.map((thread) => ({ ...thread, - isUnread: unreadThreadIds.has(thread.id), + isUnread: unreadThreads.has(thread.id), })) const paginated: PaginatedOutput = { results: decoratedPage, diff --git a/src/commands/inbox.test.ts b/src/commands/inbox.test.ts index b7cee3d..fa64806 100644 --- a/src/commands/inbox.test.ts +++ b/src/commands/inbox.test.ts @@ -272,19 +272,6 @@ describe('inbox unread mentions', () => { expect(parsedJsonOutput().map((t) => t.id)).toEqual(['thread-mention']) }) - it('--mentions takes precedence over --unread', async () => { - await createProgram().parseAsync([ - 'node', - 'tdc', - 'inbox', - '--unread', - '--mentions', - '--json', - ]) - - expect(parsedJsonOutput().map((t) => t.id)).toEqual(['thread-mention']) - }) - it('sorts mention threads before newer plain-unread threads within a channel', async () => { await createProgram().parseAsync(['node', 'tdc', 'inbox', '--json']) diff --git a/src/commands/inbox.ts b/src/commands/inbox.ts index 3a8103e..af563c9 100644 --- a/src/commands/inbox.ts +++ b/src/commands/inbox.ts @@ -59,13 +59,14 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions return { ...t, isUnread: unread !== undefined, - hasUnreadMention: unread?.directMention === true, + hasUnreadMention: unread?.directMention ?? false, } }) if (options.mentions) { inboxThreads = inboxThreads.filter((t) => t.hasUnreadMention) - } else if (options.unread) { + } + if (options.unread) { inboxThreads = inboxThreads.filter((t) => t.isUnread) } @@ -111,7 +112,7 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions } } - // Group by channel; within each channel: unread mentions, then other unreads, then reads, each newest first + // Group by channel; within each channel sort by tier (mention, unread, read), then newest first const groupedByChannel = new Map() for (const thread of inboxThreads) { const group = groupedByChannel.get(thread.channelId) || [] @@ -122,12 +123,10 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions const sortByDate = (a: (typeof inboxThreads)[0], b: (typeof inboxThreads)[0]) => new Date(b.posted).getTime() - new Date(a.posted).getTime() + const tier = (t: (typeof inboxThreads)[number]) => (t.hasUnreadMention ? 0 : t.isUnread ? 1 : 2) const sortedChannelGroups: typeof inboxThreads = [] for (const [, threads] of groupedByChannel) { - const mentions = threads.filter((t) => t.hasUnreadMention).sort(sortByDate) - const unreads = threads.filter((t) => t.isUnread && !t.hasUnreadMention).sort(sortByDate) - const reads = threads.filter((t) => !t.isUnread).sort(sortByDate) - sortedChannelGroups.push(...mentions, ...unreads, ...reads) + sortedChannelGroups.push(...threads.sort((a, b) => tier(a) - tier(b) || sortByDate(a, b))) } if (outputMode === 'ids-only') { diff --git a/src/lib/threads.ts b/src/lib/threads.ts index 4dbfe2e..0c2e452 100644 --- a/src/lib/threads.ts +++ b/src/lib/threads.ts @@ -1,6 +1,6 @@ import type { CommsApi, UnreadThread } from '@doist/comms-sdk' -/** Normalises the SDK's `{ data, version }` unread response into a Map keyed by thread ID for O(1) joins. */ +/** Normalises the SDK's `{ data, version }` unread response into a Map keyed by thread ID. */ export async function fetchUnreadThreads( client: CommsApi, workspaceId: number, @@ -8,11 +8,3 @@ export async function fetchUnreadThreads( const unread = await client.threads.getUnread(workspaceId) return new Map(unread.data.map((u) => [u.threadId, u])) } - -export async function fetchUnreadThreadIds( - client: CommsApi, - workspaceId: number, -): Promise> { - const unread = await fetchUnreadThreads(client, workspaceId) - return new Set(unread.keys()) -} From 833367de2f81658c9286339efff3119b1e422c12 Mon Sep 17 00:00:00 2001 From: Rory O'Keeffe Date: Mon, 21 Sep 2026 16:28:18 +0100 Subject: [PATCH 3/5] refactor(inbox): derive unread flags in one place. unreadFlags gives inbox and channel threads the same isUnread and hasUnreadMention fields, so the JSON shape matches across both list commands. One spelling of the row type in the inbox sort. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XYtXq3VLuv34zPKicow2jg --- src/commands/channel/threads.ts | 10 +++++----- src/commands/inbox.ts | 18 ++++++------------ src/lib/threads.ts | 10 +++++++++- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/commands/channel/threads.ts b/src/commands/channel/threads.ts index 017d809..bc02e63 100644 --- a/src/commands/channel/threads.ts +++ b/src/commands/channel/threads.ts @@ -1,5 +1,5 @@ import { outputIds, resolveOutputMode } from '@doist/cli-core' -import type { ArchiveFilter, Thread, UnreadThread } from '@doist/comms-sdk' +import type { ArchiveFilter, Thread } from '@doist/comms-sdk' import chalk from 'chalk' import { getCommsClient, getCurrentWorkspaceId } from '../../lib/api.js' import { formatRelativeDate } from '../../lib/dates.js' @@ -14,7 +14,7 @@ import { } from '../../lib/output.js' import { assertChannelIsPublic } from '../../lib/public-channels.js' import { resolveChannelRef, resolveWorkspaceRef } from '../../lib/refs.js' -import { fetchUnreadThreads } from '../../lib/threads.js' +import { fetchUnreadThreads, unreadFlags, type UnreadThreadMap } from '../../lib/threads.js' import { decodeCursor, encodeCursor } from './helpers.js' type ChannelThreadsOptions = PaginatedViewOptions & { @@ -24,7 +24,7 @@ type ChannelThreadsOptions = PaginatedViewOptions & { cursor?: string } -type DecoratedThread = Thread & { isUnread: boolean } +type DecoratedThread = Thread & ReturnType function archiveFilterToFlag(filter: ArchiveFilter | undefined): boolean | undefined { switch (filter ?? 'active') { @@ -88,7 +88,7 @@ export async function showChannelThreads( ), needsUnreadData ? fetchUnreadThreads(client, workspaceId) - : Promise.resolve(new Map()), + : Promise.resolve(new Map() as UnreadThreadMap), ]) let threads = threadsData @@ -124,7 +124,7 @@ export async function showChannelThreads( const decoratedPage: DecoratedThread[] = page.map((thread) => ({ ...thread, - isUnread: unreadThreads.has(thread.id), + ...unreadFlags(thread.id, unreadThreads), })) const paginated: PaginatedOutput = { results: decoratedPage, diff --git a/src/commands/inbox.ts b/src/commands/inbox.ts index af563c9..f738270 100644 --- a/src/commands/inbox.ts +++ b/src/commands/inbox.ts @@ -11,7 +11,7 @@ import { toDate, type PaginatedViewOptions } from '../lib/options.js' import { colors, formatJson, formatNdjson } from '../lib/output.js' import { getPublicChannelIds } from '../lib/public-channels.js' import { resolveWorkspaceRef } from '../lib/refs.js' -import { fetchUnreadThreads } from '../lib/threads.js' +import { fetchUnreadThreads, unreadFlags } from '../lib/threads.js' type InboxOptions = PaginatedViewOptions & { workspace?: string @@ -54,14 +54,7 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions fetchUnreadThreads(client, workspaceId), ]) - let inboxThreads = threads.map((t) => { - const unread = unreadThreads.get(t.id) - return { - ...t, - isUnread: unread !== undefined, - hasUnreadMention: unread?.directMention ?? false, - } - }) + let inboxThreads = threads.map((t) => ({ ...t, ...unreadFlags(t.id, unreadThreads) })) if (options.mentions) { inboxThreads = inboxThreads.filter((t) => t.hasUnreadMention) @@ -112,7 +105,7 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions } } - // Group by channel; within each channel sort by tier (mention, unread, read), then newest first + // Group by channel, then order within each channel const groupedByChannel = new Map() for (const thread of inboxThreads) { const group = groupedByChannel.get(thread.channelId) || [] @@ -120,10 +113,11 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions groupedByChannel.set(thread.channelId, group) } - const sortByDate = (a: (typeof inboxThreads)[0], b: (typeof inboxThreads)[0]) => + type InboxThread = (typeof inboxThreads)[number] + const sortByDate = (a: InboxThread, b: InboxThread) => new Date(b.posted).getTime() - new Date(a.posted).getTime() - const tier = (t: (typeof inboxThreads)[number]) => (t.hasUnreadMention ? 0 : t.isUnread ? 1 : 2) + const tier = (t: InboxThread) => (t.hasUnreadMention ? 0 : t.isUnread ? 1 : 2) const sortedChannelGroups: typeof inboxThreads = [] for (const [, threads] of groupedByChannel) { sortedChannelGroups.push(...threads.sort((a, b) => tier(a) - tier(b) || sortByDate(a, b))) diff --git a/src/lib/threads.ts b/src/lib/threads.ts index 0c2e452..40208af 100644 --- a/src/lib/threads.ts +++ b/src/lib/threads.ts @@ -1,10 +1,18 @@ import type { CommsApi, UnreadThread } from '@doist/comms-sdk' +export type UnreadThreadMap = Map + /** Normalises the SDK's `{ data, version }` unread response into a Map keyed by thread ID. */ export async function fetchUnreadThreads( client: CommsApi, workspaceId: number, -): Promise> { +): Promise { const unread = await client.threads.getUnread(workspaceId) return new Map(unread.data.map((u) => [u.threadId, u])) } + +/** Per-thread unread state for list output. `hasUnreadMention` is only ever true for an unread thread. */ +export function unreadFlags(threadId: string, unreadThreads: UnreadThreadMap) { + const unread = unreadThreads.get(threadId) + return { isUnread: unread !== undefined, hasUnreadMention: unread?.directMention ?? false } +} From 539aa1c072ac0882675947f0a7f836e6bd885dde Mon Sep 17 00:00:00 2001 From: Rory O'Keeffe Date: Mon, 21 Sep 2026 21:44:29 +0100 Subject: [PATCH 4/5] test(inbox): cover tie-break order and channel threads mention flag. Second plain-unread thread proves newest-first within a tier. Channel threads JSON asserts hasUnreadMention. CODEBASE.md names the helpers that exist now. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XYtXq3VLuv34zPKicow2jg --- CODEBASE.md | 4 ++-- src/commands/channel/threads.test.ts | 7 ++++--- src/commands/inbox.test.ts | 11 ++++++++++- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/CODEBASE.md b/CODEBASE.md index be9122f..64ff133 100644 --- a/CODEBASE.md +++ b/CODEBASE.md @@ -159,7 +159,7 @@ don't duplicate it here. - **`search-api.ts` / `search-helpers.ts`** — extended search params/response (`extendedSearch`) + shared `--search`/options wiring (`addSharedSearchOptions`, `runSearch`, `printSearchResults`). See `docs/comms-search.md`. -- **`threads.ts`** — `fetchUnreadThreadIds`; **`public-channels.ts`** — +- **`threads.ts`** — `fetchUnreadThreads`, `unreadFlags`; **`public-channels.ts`** — public-channel id cache + `assertChannelIsPublic`. - **`spinner.ts`** — re-exports `LoadingSpinner`, `withSpinner`, `startEarlySpinner`, `stopEarlySpinner` from cli-core. @@ -176,7 +176,7 @@ don't duplicate it here. ## Canonical examples - **Read:** `src/commands/inbox.ts` — `getCommsClient()`, parallel fetches - (`getInbox` + `fetchUnreadThreadIds`), public-channel handling, then + (`getInbox` + `fetchUnreadThreads`), public-channel handling, then `formatJson` / `formatNdjson` / `printEmpty`. - **Mutation with `--json`:** `src/commands/channel/create.ts` — the reference impl for `MutationOptions`, `ensureWriteAllowed`, `printDryRun`, and diff --git a/src/commands/channel/threads.test.ts b/src/commands/channel/threads.test.ts index aaf9cae..6188f80 100644 --- a/src/commands/channel/threads.test.ts +++ b/src/commands/channel/threads.test.ts @@ -86,7 +86,7 @@ function setupClient({ unread = [], }: { threads?: Thread[] - unread?: { threadId: string }[] | null + unread?: { threadId: string; directMention?: boolean }[] | null } = {}) { const mockGetThreads = vi.fn().mockResolvedValue(threads) const mockGetUnread = vi.fn().mockResolvedValue({ data: unread ?? [], version: 1 }) @@ -505,10 +505,10 @@ describe('channel threads', () => { expect(consoleSpy).toHaveBeenCalledWith('No threads in #general.') }) - it('--json emits isUnread and url without --full', async () => { + it('--json emits isUnread, hasUnreadMention and url without --full', async () => { setupClient({ threads: [createThread(1)], - unread: [{ threadId: '1' }], + unread: [{ threadId: '1', directMention: true }], }) const consoleSpy = captureConsole('log') const program = createProgram() @@ -519,6 +519,7 @@ describe('channel threads', () => { expect(output.results[0]).toMatchObject({ id: '1', isUnread: true, + hasUnreadMention: true, url: 'https://comms.todoist.com/a/1/ch/CH100/t/1', }) }) diff --git a/src/commands/inbox.test.ts b/src/commands/inbox.test.ts index fa64806..c8b811f 100644 --- a/src/commands/inbox.test.ts +++ b/src/commands/inbox.test.ts @@ -228,9 +228,17 @@ describe('inbox unread mentions', () => { posted: '2026-05-01T00:00:00Z', url: 'https://example.test/thread-mention', }, + { + id: 'thread-unread-older', + channelId: 'CH1', + title: 'Older plain unread', + posted: '2026-04-30T00:00:00Z', + url: 'https://example.test/thread-unread-older', + }, ] const unreadData = [ { threadId: 'thread-unread', channelId: 'CH1', objIndex: 3, directMention: false }, + { threadId: 'thread-unread-older', channelId: 'CH1', objIndex: 1, directMention: false }, { threadId: 'thread-mention', channelId: 'CH1', objIndex: 5, directMention: true }, ] let logSpy: ReturnType @@ -272,12 +280,13 @@ describe('inbox unread mentions', () => { expect(parsedJsonOutput().map((t) => t.id)).toEqual(['thread-mention']) }) - it('sorts mention threads before newer plain-unread threads within a channel', async () => { + it('sorts by tier, then newest first within a tier', async () => { await createProgram().parseAsync(['node', 'tdc', 'inbox', '--json']) expect(parsedJsonOutput().map((t) => t.id)).toEqual([ 'thread-mention', 'thread-unread', + 'thread-unread-older', 'thread-read', ]) }) From 1de9c469ad1ef541c45ae5888f00ede6229d8c13 Mon Sep 17 00:00:00 2001 From: Rory O'Keeffe Date: Tue, 22 Sep 2026 10:02:23 +0100 Subject: [PATCH 5/5] refactor(channel): type the empty unread map without a cast. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XYtXq3VLuv34zPKicow2jg --- src/commands/channel/threads.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/channel/threads.ts b/src/commands/channel/threads.ts index bc02e63..6dfdb65 100644 --- a/src/commands/channel/threads.ts +++ b/src/commands/channel/threads.ts @@ -88,7 +88,7 @@ export async function showChannelThreads( ), needsUnreadData ? fetchUnreadThreads(client, workspaceId) - : Promise.resolve(new Map() as UnreadThreadMap), + : Promise.resolve(new Map()), ]) let threads = threadsData