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/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 7b36b37..fa64806 100644 --- a/src/commands/inbox.test.ts +++ b/src/commands/inbox.test.ts @@ -204,3 +204,95 @@ 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('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..af563c9 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,14 +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 ?? false, + } + }) + if (options.mentions) { + inboxThreads = inboxThreads.filter((t) => t.hasUnreadMention) + } if (options.unread) { inboxThreads = inboxThreads.filter((t) => t.isUnread) } @@ -104,7 +112,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 sort by tier (mention, unread, read), then newest first const groupedByChannel = new Map() for (const thread of inboxThreads) { const group = groupedByChannel.get(thread.channelId) || [] @@ -115,11 +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 unreads = threads.filter((t) => t.isUnread).sort(sortByDate) - const reads = threads.filter((t) => !t.isUnread).sort(sortByDate) - sortedChannelGroups.push(...unreads, ...reads) + sortedChannelGroups.push(...threads.sort((a, b) => tier(a) - tier(b) || sortByDate(a, b))) } if (outputMode === 'ids-only') { @@ -158,8 +165,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 +183,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 +209,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..0c2e452 100644 --- a/src/lib/threads.ts +++ b/src/lib/threads.ts @@ -1,10 +1,10 @@ -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 Set for O(1) joins. */ -export async function fetchUnreadThreadIds( +/** 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 Set(unread.data.map((u) => u.threadId)) + return new Map(unread.data.map((u) => [u.threadId, u])) }