From dfa83f1f5fb3b03c4237954c14941b1150487d8d Mon Sep 17 00:00:00 2001 From: lmjabreu Date: Thu, 18 Jun 2026 00:02:56 +0100 Subject: [PATCH 1/4] docs(thread): document group IDs in --notify help text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tdc thread reply/create --notify` already accepts custom group IDs (partitioned via resolveNotifyIds → groups), but the help text only mentioned "user IDs". Since Comms group IDs are non-numeric base58 strings that look nothing like user IDs, the capability was effectively undiscoverable from --help. Mention groups in both option descriptions and add a group-notify example to the reply command's existing example block (alongside --close / --file). No behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/thread/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/commands/thread/index.ts b/src/commands/thread/index.ts index c5dab3c..8fead5f 100644 --- a/src/commands/thread/index.ts +++ b/src/commands/thread/index.ts @@ -50,7 +50,7 @@ Examples: withUnvalidatedChoices( new Option( '--notify ', - 'Notification recipients: EVERYONE, EVERYONE_IN_THREAD, or comma-separated user IDs (default: EVERYONE_IN_THREAD)', + 'Notification recipients: EVERYONE, EVERYONE_IN_THREAD, or comma-separated user and/or group IDs (default: EVERYONE_IN_THREAD)', ), ['EVERYONE', 'EVERYONE_IN_THREAD'], ), @@ -68,6 +68,7 @@ Examples: tdc thread reply 12345 "Sounds good!" echo "Long reply" | tdc thread reply 12345 tdc thread reply 12345 "Done" --close --json + tdc thread reply 12345 "Heads up" --notify 67890,Cbzzm11ZeYZoJYD4a6rti tdc thread reply 12345 "See attached" --file ./diagram.png tdc thread reply 12345 --file ./a.png --file ./b.pdf`, ) @@ -76,7 +77,7 @@ Examples: thread .command('create [content]') .description('Create a new thread in a channel') - .option('--notify <recipients>', 'Comma-separated user IDs to notify') + .option('--notify <recipients>', 'Comma-separated user and/or group IDs to notify') .option( '--unarchive', 'Unarchive after creation so the thread appears in your Inbox (overrides userSettings.unarchiveNewThreads when false)', From 0e3d2fbd6b3402b8d6578da14e85698c6619175c Mon Sep 17 00:00:00 2001 From: lmjabreu <hello@lmjabreu.com> Date: Thu, 18 Jun 2026 07:49:43 +0100 Subject: [PATCH 2/4] docs(skill): sync SKILL_CONTENT --notify examples with group support Per AGENTS.md, src/lib/skills/content.ts must track command-description and example changes. The reply/create --notify examples said "users" only; make them mention groups and add a group-notify example (base58 group ID next to a numeric user ID). Regenerated skills/comms-cli/SKILL.md via `sync:skill`; check:skill-sync passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- skills/comms-cli/SKILL.md | 7 ++++--- src/lib/skills/content.ts | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/skills/comms-cli/SKILL.md b/skills/comms-cli/SKILL.md index 8e6fe8c..bb3a864 100644 --- a/skills/comms-cli/SKILL.md +++ b/skills/comms-cli/SKILL.md @@ -90,14 +90,15 @@ tdc thread view <ref> --raw # Show raw markdown tdc thread create <channel-ref> "Title" "content" # Create a new thread tdc thread create <channel-ref> "Title" "content" --json # Create and return as JSON tdc thread create <channel-ref> "Title" "content" --json --full # Include all thread fields -tdc thread create <channel-ref> "Title" "content" --notify 123,456 # Notify specific users +tdc thread create <channel-ref> "Title" "content" --notify 123,456 # Notify specific users and/or groups by ID tdc thread create <channel-ref> "Title" "content" --unarchive # Land thread in author's Inbox (overrides default Comms auto-archive) tdc thread create <channel-ref> "Title" "content" --no-unarchive # Force archive even when userSettings.unarchiveNewThreads=true tdc thread create <channel-ref> "Title" "content" --dry-run # Preview without posting tdc thread create <channel-ref> "Title" --file ./a.png # Attach a file (repeatable; content optional) tdc thread reply <ref> "content" # Post a comment (notifies EVERYONE_IN_THREAD by default) tdc thread reply <ref> "content" --notify EVERYONE # Notify all workspace members -tdc thread reply <ref> "content" --notify 123,id:456 # Notify specific user IDs +tdc thread reply <ref> "content" --notify 123,id:456 # Notify specific users by ID +tdc thread reply <ref> "content" --notify 123,Cbzzm11ZeYZoJYD4a6rti # Notify a user and a group (group IDs are base58, not numeric) tdc thread reply <ref> "content" --json # Post and return comment as JSON tdc thread reply <ref> "content" --json --full # Include all comment fields tdc thread reply <ref> "content" --close # Reply and close the thread @@ -128,7 +129,7 @@ tdc thread update <ref> "New body" --json # Update and return { id, content } a tdc thread update <ref> "New body" --json --full # Update and return full thread as JSON ``` -Default `--notify` for reply is EVERYONE_IN_THREAD, which may notify more people than intended. Before posting, confirm with the user whether specific people should be notified instead (via `--notify <user-ids>`). Options: EVERYONE, EVERYONE_IN_THREAD, or comma-separated ID refs. +Default `--notify` for reply is EVERYONE_IN_THREAD, which may notify more people than intended. Before posting, confirm with the user whether specific people should be notified instead (via `--notify <ids>`). Options: EVERYONE, EVERYONE_IN_THREAD, or comma-separated user and/or group IDs. `--notify` automatically resolves IDs: group IDs are routed to the `groups` API field, user IDs to `recipients`. No special syntax needed. diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index 0335927..973f373 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -94,14 +94,15 @@ tdc thread view <ref> --raw # Show raw markdown tdc thread create <channel-ref> "Title" "content" # Create a new thread tdc thread create <channel-ref> "Title" "content" --json # Create and return as JSON tdc thread create <channel-ref> "Title" "content" --json --full # Include all thread fields -tdc thread create <channel-ref> "Title" "content" --notify 123,456 # Notify specific users +tdc thread create <channel-ref> "Title" "content" --notify 123,456 # Notify specific users and/or groups by ID tdc thread create <channel-ref> "Title" "content" --unarchive # Land thread in author's Inbox (overrides default Comms auto-archive) tdc thread create <channel-ref> "Title" "content" --no-unarchive # Force archive even when userSettings.unarchiveNewThreads=true tdc thread create <channel-ref> "Title" "content" --dry-run # Preview without posting tdc thread create <channel-ref> "Title" --file ./a.png # Attach a file (repeatable; content optional) tdc thread reply <ref> "content" # Post a comment (notifies EVERYONE_IN_THREAD by default) tdc thread reply <ref> "content" --notify EVERYONE # Notify all workspace members -tdc thread reply <ref> "content" --notify 123,id:456 # Notify specific user IDs +tdc thread reply <ref> "content" --notify 123,id:456 # Notify specific users by ID +tdc thread reply <ref> "content" --notify 123,Cbzzm11ZeYZoJYD4a6rti # Notify a user and a group (group IDs are base58, not numeric) tdc thread reply <ref> "content" --json # Post and return comment as JSON tdc thread reply <ref> "content" --json --full # Include all comment fields tdc thread reply <ref> "content" --close # Reply and close the thread @@ -132,7 +133,7 @@ tdc thread update <ref> "New body" --json # Update and return { id, content } a tdc thread update <ref> "New body" --json --full # Update and return full thread as JSON \`\`\` -Default \`--notify\` for reply is EVERYONE_IN_THREAD, which may notify more people than intended. Before posting, confirm with the user whether specific people should be notified instead (via \`--notify <user-ids>\`). Options: EVERYONE, EVERYONE_IN_THREAD, or comma-separated ID refs. +Default \`--notify\` for reply is EVERYONE_IN_THREAD, which may notify more people than intended. Before posting, confirm with the user whether specific people should be notified instead (via \`--notify <ids>\`). Options: EVERYONE, EVERYONE_IN_THREAD, or comma-separated user and/or group IDs. \`--notify\` automatically resolves IDs: group IDs are routed to the \`groups\` API field, user IDs to \`recipients\`. No special syntax needed. From 2f06633e35d1969e9bdd5a98e5e5004d65d312f8 Mon Sep 17 00:00:00 2001 From: lmjabreu <hello@lmjabreu.com> Date: Thu, 18 Jun 2026 10:17:25 +0100 Subject: [PATCH 3/4] feat(conversation): add conversation list command List DMs and group conversations filtered by participant, name, or kind. Reuses the existing plumbing: getAllConversations becomes getConversationsByState(state), and listConversationsWithUser becomes the shared renderConversationList. Defaults to active conversations; --state opts into archived. SKILL_CONTENT and README updated, SKILL.md regenerated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- README.md | 1 + skills/comms-cli/SKILL.md | 9 + .../conversation/conversation.test.ts | 362 ++++++++++++++++++ src/commands/conversation/helpers.ts | 49 ++- src/commands/conversation/index.ts | 52 ++- src/commands/conversation/list.ts | 90 +++++ src/commands/conversation/with.ts | 12 +- src/lib/errors.ts | 1 + src/lib/skills/content.ts | 9 + 9 files changed, 575 insertions(+), 10 deletions(-) create mode 100644 src/commands/conversation/list.ts diff --git a/README.md b/README.md index bcfe08c..27785a1 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ tdc thread reply <ref> # reply to a thread tdc thread rename <ref> "New title" # rename a thread tdc thread update <ref> "New body" # edit a thread's body (first post) tdc conversation unread # list unread conversations +tdc conversation list # list conversations (--kind, --participant, --name, --state) tdc conversation view <ref> # view conversation messages tdc msg view <ref> # view a conversation message tdc search "keyword" # search across workspace diff --git a/skills/comms-cli/SKILL.md b/skills/comms-cli/SKILL.md index bb3a864..695b3e6 100644 --- a/skills/comms-cli/SKILL.md +++ b/skills/comms-cli/SKILL.md @@ -154,6 +154,14 @@ tdc comment delete <comment-ref> --yes --json # Delete and return status ```bash tdc conversation unread # List unread conversations +tdc conversation list # List active conversations (DMs and groups) +tdc conversation list --kind group # Only group conversations (3+ people) +tdc conversation list --kind direct # Only 1:1 conversations (and your self-DM) +tdc conversation list --participant "Jane" # Only conversations including these users (comma-separated) +tdc conversation list --name "release" # Filter by title substring (case-insensitive) +tdc conversation list --state archived # Archived conversations only (active|all|archived; default active) +tdc conversation list --snippet # Include the latest message snippet +tdc conversation list --limit 20 --json # Cap rows and output as JSON tdc conversation <conversation-ref> # View conversation (shorthand for view) tdc conversation view <conversation-ref> # View conversation messages tdc conversation with <user-ref> # Find your 1:1 DM with a user @@ -430,6 +438,7 @@ tdc thread view <thread-id> **Check DMs:** ```bash tdc conversation unread --json +tdc conversation list --kind group --json # find a group DM by participants/name tdc conversation view <conversation-id> tdc conversation with "Alice Example" tdc conversation reply <id> "Got it, thanks!" diff --git a/src/commands/conversation/conversation.test.ts b/src/commands/conversation/conversation.test.ts index c750f68..81a5d08 100644 --- a/src/commands/conversation/conversation.test.ts +++ b/src/commands/conversation/conversation.test.ts @@ -381,6 +381,368 @@ describe('conversation with', () => { }) }) +describe('conversation list', () => { + beforeEach(() => { + vi.clearAllMocks() + refsMocks.resolveUserRefs.mockResolvedValue([2]) + }) + + const standardUsers = { + 1: { id: 1, fullName: 'Me' }, + 2: { id: 2, fullName: 'Alice Example' }, + 3: { id: 3, fullName: 'Bob Example' }, + } + + function titled( + id: number, + userIds: number[], + lastActive: string, + title: string, + ): TestConversation { + return { ...createConversation(id, userIds, lastActive), title } + } + + it('lists active conversations sorted by last activity', async () => { + const client = createClient({ + activeConversations: [ + titled(42, [1, 2], '2026-03-08T10:00:00.000Z', 'Older direct'), + titled(43, [1, 2, 3], '2026-03-09T10:00:00.000Z', 'Newer group'), + ], + users: standardUsers, + }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'conversation', 'list']) + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') + // Newest first. + expect(output.indexOf('Newer group')).toBeLessThan(output.indexOf('Older direct')) + expect(output).toContain('id:43') + expect(output).toContain('id:42') + // Active-only default never touches the archived fetch. + expect(client.conversations.getConversations).toHaveBeenCalledWith({ workspaceId: 1 }) + expect(client.conversations.getConversations).not.toHaveBeenCalledWith({ + workspaceId: 1, + archived: true, + }) + }) + + it('filters to conversations that include a given participant', async () => { + const client = createClient({ + activeConversations: [ + titled(42, [1, 2], '2026-03-08T10:00:00.000Z', 'With Alice'), + titled(43, [1, 3], '2026-03-09T10:00:00.000Z', 'With Bob'), + ], + users: standardUsers, + }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync([ + 'node', + 'tdc', + 'conversation', + 'list', + '--participant', + 'Alice', + '--json', + ]) + + expect(refsMocks.resolveUserRefs).toHaveBeenCalledWith('Alice', 1) + expect(JSON.parse(consoleSpy.mock.calls[0][0]).map((c: { id: string }) => c.id)).toEqual([ + '42', + ]) + }) + + it('requires ALL given participants to be present', async () => { + refsMocks.resolveUserRefs.mockResolvedValue([2, 3]) + const client = createClient({ + activeConversations: [ + titled(42, [1, 2], '2026-03-08T10:00:00.000Z', 'Just Alice'), + titled(43, [1, 2, 3], '2026-03-09T10:00:00.000Z', 'Alice and Bob'), + ], + users: standardUsers, + }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync([ + 'node', + 'tdc', + 'conversation', + 'list', + '--participant', + 'Alice,Bob', + '--json', + ]) + + expect(JSON.parse(consoleSpy.mock.calls[0][0]).map((c: { id: string }) => c.id)).toEqual([ + '43', + ]) + }) + + it('filters by case-insensitive title substring with --name', async () => { + const client = createClient({ + activeConversations: [ + titled(42, [1, 2], '2026-03-08T10:00:00.000Z', 'Release planning'), + titled(43, [1, 3], '2026-03-09T10:00:00.000Z', 'Lunch plans'), + ], + users: standardUsers, + }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync([ + 'node', + 'tdc', + 'conversation', + 'list', + '--name', + 'release', + '--json', + ]) + + expect(JSON.parse(consoleSpy.mock.calls[0][0]).map((c: { id: string }) => c.id)).toEqual([ + '42', + ]) + }) + + it('lists only group conversations with --kind group', async () => { + const client = createClient({ + activeConversations: [ + titled(42, [1, 2], '2026-03-08T10:00:00.000Z', 'Direct'), + titled(43, [1, 2, 3], '2026-03-09T10:00:00.000Z', 'Group'), + ], + users: standardUsers, + }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync([ + 'node', + 'tdc', + 'conversation', + 'list', + '--kind', + 'group', + '--json', + ]) + + expect(JSON.parse(consoleSpy.mock.calls[0][0]).map((c: { id: string }) => c.id)).toEqual([ + '43', + ]) + }) + + it('lists only 1:1s (and the self-conversation) with --kind direct', async () => { + const client = createClient({ + activeConversations: [ + titled(10, [1], '2026-03-10T10:00:00.000Z', 'Self'), + titled(42, [1, 2], '2026-03-08T10:00:00.000Z', 'Direct'), + titled(43, [1, 2, 3], '2026-03-09T10:00:00.000Z', 'Group'), + ], + users: standardUsers, + }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync([ + 'node', + 'tdc', + 'conversation', + 'list', + '--kind', + 'direct', + '--json', + ]) + + expect( + JSON.parse(consoleSpy.mock.calls[0][0]) + .map((c: { id: string }) => c.id) + .sort(), + ).toEqual(['10', '42']) + }) + + it('rejects an invalid --kind value via Commander choices', async () => { + const client = createClient({ activeConversations: [], users: standardUsers }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + + await expect( + program.parseAsync(['node', 'tdc', 'conversation', 'list', '--kind', 'nope']), + ).rejects.toThrow(/Allowed choices are group, direct/) + }) + + it('fetches only archived conversations with --state archived', async () => { + const client = createClient({ + activeConversations: [titled(42, [1, 2], '2026-03-08T10:00:00.000Z', 'Active')], + archivedConversations: [titled(43, [1, 3], '2026-03-09T10:00:00.000Z', 'Archived')], + users: standardUsers, + }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync([ + 'node', + 'tdc', + 'conversation', + 'list', + '--state', + 'archived', + '--json', + ]) + + expect(client.conversations.getConversations).toHaveBeenCalledWith({ + workspaceId: 1, + archived: true, + }) + expect(client.conversations.getConversations).not.toHaveBeenCalledWith({ workspaceId: 1 }) + expect(JSON.parse(consoleSpy.mock.calls[0][0]).map((c: { id: string }) => c.id)).toEqual([ + '43', + ]) + }) + + it('includes archived conversations with --state all', async () => { + const client = createClient({ + activeConversations: [titled(42, [1, 2], '2026-03-08T10:00:00.000Z', 'Active')], + archivedConversations: [titled(43, [1, 3], '2026-03-09T10:00:00.000Z', 'Archived')], + users: standardUsers, + }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync([ + 'node', + 'tdc', + 'conversation', + 'list', + '--state', + 'all', + '--json', + ]) + + expect( + JSON.parse(consoleSpy.mock.calls[0][0]) + .map((c: { id: string }) => c.id) + .sort(), + ).toEqual(['42', '43']) + }) + + it('caps the number of rows with --limit', async () => { + const client = createClient({ + activeConversations: [ + titled(42, [1, 2], '2026-03-08T10:00:00.000Z', 'One'), + titled(43, [1, 3], '2026-03-09T10:00:00.000Z', 'Two'), + titled(44, [1, 2, 3], '2026-03-10T10:00:00.000Z', 'Three'), + ], + users: standardUsers, + }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'conversation', 'list', '--limit', '2', '--json']) + + // Newest-first, capped at 2. + expect(JSON.parse(consoleSpy.mock.calls[0][0]).map((c: { id: string }) => c.id)).toEqual([ + '44', + '43', + ]) + }) + + it('rejects a non-positive --limit value', async () => { + const client = createClient({ activeConversations: [], users: standardUsers }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + + await expect( + program.parseAsync(['node', 'tdc', 'conversation', 'list', '--limit', '0']), + ).rejects.toHaveProperty('code', 'INVALID_LIMIT') + }) + + it('filters JSON fields unless --full is set', async () => { + const client = createClient({ + activeConversations: [titled(42, [1, 2], '2026-03-08T10:00:00.000Z', 'Release')], + users: standardUsers, + }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'conversation', 'list', '--json']) + + const item = JSON.parse(consoleSpy.mock.calls[0][0])[0] + expect(Object.keys(item).sort()).toEqual([ + 'archived', + 'id', + 'lastActive', + 'messageCount', + 'title', + 'userIds', + 'workspaceId', + ]) + expect(item.participantNames).toBeUndefined() + expect(item.snippet).toBeUndefined() + + consoleSpy.mockClear() + + await program.parseAsync(['node', 'tdc', 'conversation', 'list', '--json', '--full']) + + const fullItem = JSON.parse(consoleSpy.mock.calls[0][0])[0] + expect(fullItem.participantNames).toEqual(['Me', 'Alice Example']) + expect(fullItem.snippet).toBe('Snippet 42') + expect(fullItem.url).toBeTruthy() + }) + + it('prints the empty message when nothing matches', async () => { + const client = createClient({ activeConversations: [], users: standardUsers }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'conversation', 'list']) + + expect(consoleSpy).toHaveBeenCalledWith('No matching conversations found.') + }) + + it('errors when both positional and --workspace are provided', async () => { + const program = createProgram() + + await expect( + program.parseAsync([ + 'node', + 'tdc', + 'conversation', + 'list', + 'Doist', + '--workspace', + 'Other', + ]), + ).rejects.toThrow('Cannot specify workspace both as argument and --workspace flag') + }) +}) + describe('conversation view machine output', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/commands/conversation/helpers.ts b/src/commands/conversation/helpers.ts index b7cba6e..dd23647 100644 --- a/src/commands/conversation/helpers.ts +++ b/src/commands/conversation/helpers.ts @@ -18,6 +18,26 @@ export type ConversationWithOptions = PaginatedViewOptions & { snippet?: boolean } +export type ConversationListOptions = ViewOptions & { + workspace?: string + participant?: string + name?: string + kind?: string + state?: string + snippet?: boolean + limit?: string +} + +/** Fields the conversation renderer reads — satisfied by both `with` and `list` options. */ +export type ConversationRenderOptions = { + json?: boolean + ndjson?: boolean + full?: boolean + snippet?: boolean +} + +export type ConversationState = 'active' | 'all' | 'archived' + export type ReplyOptions = MutationOptions & { file?: string[] } export type MuteOptions = MutationOptions & { minutes?: string } @@ -49,8 +69,31 @@ export function sortByLastActiveDescending(a: Conversation, b: Conversation): nu return new Date(b.lastActive).getTime() - new Date(a.lastActive).getTime() } -export async function getAllConversations(workspaceId: number): Promise<Conversation[]> { +/** + * Fetch a workspace's conversations for the requested {@link ConversationState}, + * sorted by last activity (newest first). `active` and `archived` are single + * calls; `all` fetches both and dedupes by id (the SDK's `getConversations` is + * not paginated, so each state returns the full set in one call). + */ +export async function getConversationsByState( + workspaceId: number, + state: ConversationState = 'all', +): Promise<Conversation[]> { const client = await getCommsClient() + + if (state === 'active') { + const active = await client.conversations.getConversations({ workspaceId }) + return [...active].sort(sortByLastActiveDescending) + } + + if (state === 'archived') { + const archived = await client.conversations.getConversations({ + workspaceId, + archived: true, + }) + return [...archived].sort(sortByLastActiveDescending) + } + const [active, archived] = await Promise.all([ client.conversations.getConversations({ workspaceId }), client.conversations.getConversations({ workspaceId, archived: true }), @@ -111,10 +154,10 @@ export async function findDirectConversation( } } -export async function listConversationsWithUser( +export async function renderConversationList( conversations: Conversation[], workspaceId: number, - options: ConversationWithOptions, + options: ConversationRenderOptions, ): Promise<void> { if (conversations.length === 0) { printEmpty({ diff --git a/src/commands/conversation/index.ts b/src/commands/conversation/index.ts index 28a5de9..5739dce 100644 --- a/src/commands/conversation/index.ts +++ b/src/commands/conversation/index.ts @@ -1,6 +1,8 @@ -import { Command } from 'commander' +import { Command, Option } from 'commander' +import { withCaseInsensitiveChoices } from '../../lib/completion.js' import { collect } from '../../lib/options.js' import { markConversationDone } from './done.js' +import { listConversations } from './list.js' import { muteConversation } from './mute.js' import { replyToConversation } from './reply.js' import { unmuteConversation } from './unmute.js' @@ -30,6 +32,54 @@ Examples: ) .action(showUnread) + conversation + .command('list [workspace-ref]') + .description('List conversations, filtered by participant, name, or kind') + .option('--workspace <ref>', 'Workspace ID or name') + .option( + '--participant <user-refs>', + 'Only conversations including these users (comma-separated: id:N, email, or name)', + ) + .option('--name <substr>', 'Filter by conversation title (case-insensitive substring)') + .addOption( + withCaseInsensitiveChoices( + new Option('--kind <kind>', 'Filter by kind: group (3+ people) or direct (1:1)'), + ['group', 'direct'], + ), + ) + .addOption( + withCaseInsensitiveChoices( + new Option( + '--state <state>', + 'Conversation state: active, all, or archived (default: active)', + ), + ['active', 'all', 'archived'], + ), + ) + .option('--snippet', 'Include the latest message snippet in text output') + .option('--limit <n>', 'Maximum conversations to show (default: all)') + .option('--json', 'Output as JSON') + .option('--ndjson', 'Output as newline-delimited JSON') + .option('--full', 'Include all fields in JSON output') + .addHelpText( + 'after', + ` +Examples: + tdc conversation list + tdc conversation list --kind group + tdc conversation list --participant "Jane Smith" + tdc conversation list --participant alice@doist.com,bob@doist.com --kind group + tdc conversation list --name "release" --snippet + tdc conversation list --state archived --json + tdc conversation list --kind direct --limit 20 + +Notes: + Defaults to active conversations. --participant keeps conversations that + include ALL of the given users. --kind group lists conversations with 3+ + people; --kind direct lists 1:1s (and your self-conversation).`, + ) + .action(listConversations) + conversation .command('view [conversation-ref]', { isDefault: true }) .description('Display a conversation with its messages') diff --git a/src/commands/conversation/list.ts b/src/commands/conversation/list.ts new file mode 100644 index 0000000..ad53b86 --- /dev/null +++ b/src/commands/conversation/list.ts @@ -0,0 +1,90 @@ +import { getCurrentWorkspaceId } from '../../lib/api.js' +import { CliError } from '../../lib/errors.js' +import { resolveUserRefs, resolveWorkspaceRef } from '../../lib/refs.js' +import { + type ConversationListOptions, + type ConversationState, + getConversationsByState, + renderConversationList, +} from './helpers.js' + +type ConversationKind = 'group' | 'direct' + +// `--kind` and `--state` are validated by Commander (withCaseInsensitiveChoices), +// so these only narrow the already-restricted string to its union type and apply +// the `active` default. +function resolveState(state: string | undefined): ConversationState { + return state === 'all' || state === 'archived' ? state : 'active' +} + +function resolveKind(kind: string | undefined): ConversationKind | undefined { + return kind === 'group' || kind === 'direct' ? kind : undefined +} + +function parseLimit(value: string | undefined): number | undefined { + if (value === undefined) return undefined + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new CliError( + 'INVALID_LIMIT', + `Invalid --limit value: ${value} (must be a positive integer)`, + ) + } + return parsed +} + +export async function listConversations( + workspaceRef: string | undefined, + options: ConversationListOptions, +): Promise<void> { + if (workspaceRef && options.workspace) { + throw new CliError( + 'CONFLICTING_OPTIONS', + 'Cannot specify workspace both as argument and --workspace flag', + ) + } + + const state = resolveState(options.state) + const kind = resolveKind(options.kind) + const limit = parseLimit(options.limit) + + let workspaceId: number + const ref = workspaceRef || options.workspace + if (ref) { + const workspace = await resolveWorkspaceRef(ref) + workspaceId = workspace.id + } else { + workspaceId = await getCurrentWorkspaceId() + } + + let conversations = await getConversationsByState(workspaceId, state) + + if (options.participant) { + const participantIds = await resolveUserRefs(options.participant, workspaceId) + // Keep conversations that include every requested participant. + conversations = conversations.filter((conversation) => + participantIds.every((id) => conversation.userIds.includes(id)), + ) + } + + if (options.name) { + const needle = options.name.toLowerCase() + conversations = conversations.filter((conversation) => + (conversation.title ?? '').toLowerCase().includes(needle), + ) + } + + if (kind) { + // Direct = 1:1 (userIds: you + them === 2) or your self-conversation (=== 1). + // Group = 3+ participants. + conversations = conversations.filter((conversation) => + kind === 'group' ? conversation.userIds.length > 2 : conversation.userIds.length <= 2, + ) + } + + if (limit !== undefined) { + conversations = conversations.slice(0, limit) + } + + await renderConversationList(conversations, workspaceId, options) +} diff --git a/src/commands/conversation/with.ts b/src/commands/conversation/with.ts index 0a49a47..4bb6633 100644 --- a/src/commands/conversation/with.ts +++ b/src/commands/conversation/with.ts @@ -4,8 +4,8 @@ import { resolveUserRefs, resolveWorkspaceRef } from '../../lib/refs.js' import { type ConversationWithOptions, findDirectConversation, - getAllConversations, - listConversationsWithUser, + getConversationsByState, + renderConversationList, } from './helpers.js' export async function findConversationWithUser( @@ -43,12 +43,12 @@ export async function findConversationWithUser( ]) if (options.includeGroups) { - const conversations = await getAllConversations(workspaceId) + const conversations = await getConversationsByState(workspaceId) const matchingConversations = conversations.filter((conversation) => conversation.userIds.includes(targetUser.id), ) - await listConversationsWithUser(matchingConversations, workspaceId, options) + await renderConversationList(matchingConversations, workspaceId, options) return } @@ -60,7 +60,7 @@ export async function findConversationWithUser( if (!directConversation) { if (options.json || options.ndjson) { - await listConversationsWithUser([], workspaceId, options) + await renderConversationList([], workspaceId, options) return } @@ -73,5 +73,5 @@ export async function findConversationWithUser( return } - await listConversationsWithUser([directConversation], workspaceId, options) + await renderConversationList([directConversation], workspaceId, options) } diff --git a/src/lib/errors.ts b/src/lib/errors.ts index a0ddae5..ae9f0e6 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -25,6 +25,7 @@ export type ErrorCode = | 'INVALID_CURSOR' | 'INVALID_DATE' | 'INVALID_ID' + | 'INVALID_LIMIT' | 'INVALID_MINUTES' | 'INVALID_REF' | 'INVALID_SCOPE' diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index 973f373..de08000 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -158,6 +158,14 @@ tdc comment delete <comment-ref> --yes --json # Delete and return status \`\`\`bash tdc conversation unread # List unread conversations +tdc conversation list # List active conversations (DMs and groups) +tdc conversation list --kind group # Only group conversations (3+ people) +tdc conversation list --kind direct # Only 1:1 conversations (and your self-DM) +tdc conversation list --participant "Jane" # Only conversations including these users (comma-separated) +tdc conversation list --name "release" # Filter by title substring (case-insensitive) +tdc conversation list --state archived # Archived conversations only (active|all|archived; default active) +tdc conversation list --snippet # Include the latest message snippet +tdc conversation list --limit 20 --json # Cap rows and output as JSON tdc conversation <conversation-ref> # View conversation (shorthand for view) tdc conversation view <conversation-ref> # View conversation messages tdc conversation with <user-ref> # Find your 1:1 DM with a user @@ -434,6 +442,7 @@ tdc thread view <thread-id> **Check DMs:** \`\`\`bash tdc conversation unread --json +tdc conversation list --kind group --json # find a group DM by participants/name tdc conversation view <conversation-id> tdc conversation with "Alice Example" tdc conversation reply <id> "Got it, thanks!" From fbf0d9b2685761378eaf986ed38e58326757c40a Mon Sep 17 00:00:00 2001 From: lmjabreu <hello@lmjabreu.com> Date: Thu, 18 Jun 2026 12:04:41 +0100 Subject: [PATCH 4/4] fix(conversation): address list review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three doistbot P2s on the conversation list command: - Resolve --participant against the full roster (includeRemoved) so a participant who has left the workspace still matches; the renderer already shows removed participants, and archived DMs often include them. - Resolve participants and fetch conversations concurrently (Promise.all) rather than sequentially — they're independent once the workspace is known. - Skip the workspace-wide user-map fetch for --json/--ndjson without --full, where participantNames are filtered back out anyway (also speeds up conversation with machine output, which shares the renderer). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- src/commands/conversation/conversation.test.ts | 8 +++++++- src/commands/conversation/helpers.ts | 11 +++++++++++ src/commands/conversation/list.ts | 16 +++++++++++++--- src/lib/refs.ts | 8 ++++++-- 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/commands/conversation/conversation.test.ts b/src/commands/conversation/conversation.test.ts index 81a5d08..63f6efb 100644 --- a/src/commands/conversation/conversation.test.ts +++ b/src/commands/conversation/conversation.test.ts @@ -453,7 +453,9 @@ describe('conversation list', () => { '--json', ]) - expect(refsMocks.resolveUserRefs).toHaveBeenCalledWith('Alice', 1) + // Resolve against the full roster so a participant who has left the + // workspace still matches (renderer shows removed participants). + expect(refsMocks.resolveUserRefs).toHaveBeenCalledWith('Alice', 1, { includeRemoved: true }) expect(JSON.parse(consoleSpy.mock.calls[0][0]).map((c: { id: string }) => c.id)).toEqual([ '42', ]) @@ -703,6 +705,8 @@ describe('conversation list', () => { ]) expect(item.participantNames).toBeUndefined() expect(item.snippet).toBeUndefined() + // Machine output without --full must not pay for the workspace user map. + expect(client.workspaceUsers.getWorkspaceUsers).not.toHaveBeenCalled() consoleSpy.mockClear() @@ -712,6 +716,8 @@ describe('conversation list', () => { expect(fullItem.participantNames).toEqual(['Me', 'Alice Example']) expect(fullItem.snippet).toBe('Snippet 42') expect(fullItem.url).toBeTruthy() + // --full needs names, so the map fetch happens here. + expect(client.workspaceUsers.getWorkspaceUsers).toHaveBeenCalled() }) it('prints the empty message when nothing matches', async () => { diff --git a/src/commands/conversation/helpers.ts b/src/commands/conversation/helpers.ts index dd23647..783c136 100644 --- a/src/commands/conversation/helpers.ts +++ b/src/commands/conversation/helpers.ts @@ -168,6 +168,17 @@ export async function renderConversationList( return } + // Machine output without --full filters `participantNames` back out, so skip + // the workspace-wide user-map fetch whose names would never be emitted. + if ((options.json || options.ndjson) && !options.full) { + if (options.json) { + console.log(formatJson(conversations, 'conversation', false)) + } else { + console.log(formatNdjson(conversations, 'conversation', false)) + } + return + } + const client = await getCommsClient() const userMap = await buildUserNameMap(workspaceId, client) diff --git a/src/commands/conversation/list.ts b/src/commands/conversation/list.ts index ad53b86..f515458 100644 --- a/src/commands/conversation/list.ts +++ b/src/commands/conversation/list.ts @@ -57,10 +57,20 @@ export async function listConversations( workspaceId = await getCurrentWorkspaceId() } - let conversations = await getConversationsByState(workspaceId, state) + // Resolve participants and fetch conversations concurrently — they're + // independent once the workspace is known. `includeRemoved` so a participant + // who has since left the workspace still resolves (the renderer already + // shows removed participants, and archived DMs often include them). + const [participantIds, fetched] = await Promise.all([ + options.participant + ? resolveUserRefs(options.participant, workspaceId, { includeRemoved: true }) + : Promise.resolve(null), + getConversationsByState(workspaceId, state), + ]) - if (options.participant) { - const participantIds = await resolveUserRefs(options.participant, workspaceId) + let conversations = fetched + + if (participantIds) { // Keep conversations that include every requested participant. conversations = conversations.filter((conversation) => participantIds.every((id) => conversation.userIds.includes(id)), diff --git a/src/lib/refs.ts b/src/lib/refs.ts index e2d8cb2..3b4b633 100644 --- a/src/lib/refs.ts +++ b/src/lib/refs.ts @@ -679,12 +679,16 @@ export async function resolveChannelMemberRefs( return { userIds, expandedFrom } } -export async function resolveUserRefs(refs: string, workspaceId: number): Promise<number[]> { +export async function resolveUserRefs( + refs: string, + workspaceId: number, + options: { includeRemoved?: boolean } = {}, +): Promise<number[]> { const numericIds = parseNumericIdRefs(refs, 'user') if (numericIds) return numericIds const { getWorkspaceUsers } = await import('./api.js') - const users = await getWorkspaceUsers(workspaceId) + const users = await getWorkspaceUsers(workspaceId, { includeRemoved: options.includeRemoved }) const parts = refs.split(',').map((r) => r.trim()) const ids: number[] = []