Skip to content

Commit 92478c9

Browse files
fix: make thread view --unread filter correctly across all output modes (#86)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 172dd00 commit 92478c9

2 files changed

Lines changed: 274 additions & 26 deletions

File tree

src/__tests__/thread.test.ts

Lines changed: 238 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { Command } from 'commander'
22
import { beforeEach, describe, expect, it, vi } from 'vitest'
33

4-
vi.mock('../lib/api.js', () => ({
5-
getTwistClient: vi.fn().mockRejectedValue(new Error('MOCK_API_REACHED')),
4+
const apiMocks = vi.hoisted(() => ({
5+
getTwistClient: vi.fn(),
66
}))
77

8+
vi.mock('../lib/api.js', () => apiMocks)
9+
810
vi.mock('../lib/public-channels.js', () => ({
911
assertChannelIsPublic: vi.fn(),
1012
}))
@@ -22,6 +24,96 @@ vi.mock('chalk')
2224

2325
import { registerThreadCommand } from '../commands/thread.js'
2426

27+
function createThread(id: number) {
28+
return {
29+
id,
30+
title: 'Test Thread',
31+
content: 'Thread body',
32+
creator: 1,
33+
channelId: 100,
34+
workspaceId: 10,
35+
posted: new Date('2026-03-01T00:00:00.000Z'),
36+
commentCount: 3,
37+
isArchived: false,
38+
reactions: [],
39+
}
40+
}
41+
42+
function createComment(id: number, objIndex: number) {
43+
return {
44+
id,
45+
content: `Comment ${id}`,
46+
creator: 2,
47+
threadId: 500,
48+
posted: new Date('2026-03-02T00:00:00.000Z'),
49+
reactions: [],
50+
objIndex,
51+
}
52+
}
53+
54+
function createClient({
55+
thread = createThread(500),
56+
comments = [] as ReturnType<typeof createComment>[],
57+
unreadThreads = [] as Array<{
58+
threadId: number
59+
channelId: number
60+
objIndex: number
61+
directMention: boolean
62+
}>,
63+
users = {} as Record<number, { id: number; name: string }>,
64+
channel = { id: 100, name: 'General' },
65+
} = {}) {
66+
return {
67+
threads: {
68+
getThread: vi.fn((_id: number, options?: { batch?: boolean }) => {
69+
if (options?.batch) return { kind: 'thread', id: _id }
70+
return Promise.resolve(thread)
71+
}),
72+
getUnread: vi.fn(async () => unreadThreads),
73+
},
74+
comments: {
75+
getComments: vi.fn((_args: unknown, options?: { batch?: boolean }) => {
76+
if (options?.batch) return { kind: 'comments' }
77+
return Promise.resolve(comments)
78+
}),
79+
getComment: vi.fn(),
80+
},
81+
channels: {
82+
getChannel: vi.fn((_id: number, options?: { batch?: boolean }) => {
83+
if (options?.batch) return { kind: 'channel' }
84+
return Promise.resolve(channel)
85+
}),
86+
},
87+
workspaceUsers: {
88+
getUserById: vi.fn(
89+
(
90+
{ userId }: { workspaceId: number; userId: number },
91+
options?: { batch?: boolean },
92+
) => {
93+
if (options?.batch) return { kind: 'user', userId }
94+
return Promise.resolve(users[userId])
95+
},
96+
),
97+
},
98+
batch: vi.fn(async (...requests: Array<{ kind: string; id?: number; userId?: number }>) =>
99+
requests.map((request) => {
100+
if (request.kind === 'thread') return { data: thread }
101+
if (request.kind === 'comments') return { data: comments }
102+
if (request.kind === 'channel') return { data: channel }
103+
if (request.kind === 'user' && request.userId) {
104+
return {
105+
data: users[request.userId] ?? {
106+
id: request.userId,
107+
name: `user:${request.userId}`,
108+
},
109+
}
110+
}
111+
throw new Error(`Unexpected batch request: ${JSON.stringify(request)}`)
112+
}),
113+
),
114+
}
115+
}
116+
25117
function createProgram() {
26118
const program = new Command()
27119
program.exitOverride()
@@ -32,6 +124,7 @@ function createProgram() {
32124
describe('thread implicit view', () => {
33125
beforeEach(() => {
34126
vi.clearAllMocks()
127+
apiMocks.getTwistClient.mockRejectedValue(new Error('MOCK_API_REACHED'))
35128
})
36129

37130
it('tw thread <ref> routes to view (not unknown command)', async () => {
@@ -67,3 +160,146 @@ describe('thread implicit view', () => {
67160
consoleSpy.mockRestore()
68161
})
69162
})
163+
164+
describe('thread view --unread', () => {
165+
beforeEach(() => {
166+
vi.resetAllMocks()
167+
})
168+
169+
it('shows original post and "No unread comments" when thread has no unread data', async () => {
170+
const client = createClient({
171+
comments: [createComment(1, 1), createComment(2, 2)],
172+
unreadThreads: [],
173+
users: { 1: { id: 1, name: 'Alice' }, 2: { id: 2, name: 'Bob' } },
174+
})
175+
apiMocks.getTwistClient.mockResolvedValue(client)
176+
177+
const program = createProgram()
178+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
179+
180+
await program.parseAsync(['node', 'tw', 'thread', 'view', '500', '--unread'])
181+
182+
const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n')
183+
expect(output).toContain('Test Thread')
184+
expect(output).toContain('Thread body')
185+
expect(output).toContain('No unread comments.')
186+
187+
consoleSpy.mockRestore()
188+
})
189+
190+
it('filters to only unread comments in human-readable output', async () => {
191+
const client = createClient({
192+
comments: [createComment(1, 1), createComment(2, 2), createComment(3, 3)],
193+
unreadThreads: [{ threadId: 500, channelId: 100, objIndex: 1, directMention: false }],
194+
users: { 1: { id: 1, name: 'Alice' }, 2: { id: 2, name: 'Bob' } },
195+
})
196+
apiMocks.getTwistClient.mockResolvedValue(client)
197+
198+
const program = createProgram()
199+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
200+
201+
await program.parseAsync(['node', 'tw', 'thread', 'view', '500', '--unread'])
202+
203+
const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n')
204+
// Should show original post
205+
expect(output).toContain('Thread body')
206+
// Should show unread comments (objIndex 2 and 3, which are > 1)
207+
expect(output).toContain('Comment 2')
208+
expect(output).toContain('Comment 3')
209+
// Should NOT show comment 1 (objIndex 1, which is <= lastReadObjIndex 1)
210+
expect(output).not.toContain('Comment 1')
211+
// Should show unread separator
212+
expect(output).toContain('UNREAD (2 new)')
213+
214+
consoleSpy.mockRestore()
215+
})
216+
217+
it('filters comments in --json output when --unread is set', async () => {
218+
const client = createClient({
219+
comments: [createComment(1, 1), createComment(2, 2), createComment(3, 3)],
220+
unreadThreads: [{ threadId: 500, channelId: 100, objIndex: 2, directMention: false }],
221+
users: { 1: { id: 1, name: 'Alice' }, 2: { id: 2, name: 'Bob' } },
222+
})
223+
apiMocks.getTwistClient.mockResolvedValue(client)
224+
225+
const program = createProgram()
226+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
227+
228+
await program.parseAsync(['node', 'tw', 'thread', 'view', '500', '--unread', '--json'])
229+
230+
const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0])
231+
expect(jsonOutput.thread.id).toBe(500)
232+
// Only comment 3 is unread (objIndex 3 > lastReadObjIndex 2)
233+
expect(jsonOutput.comments).toHaveLength(1)
234+
expect(jsonOutput.comments[0].id).toBe(3)
235+
236+
consoleSpy.mockRestore()
237+
})
238+
239+
it('returns empty comments in --json output when no unread data exists', async () => {
240+
const client = createClient({
241+
comments: [createComment(1, 1), createComment(2, 2)],
242+
unreadThreads: [],
243+
users: { 1: { id: 1, name: 'Alice' }, 2: { id: 2, name: 'Bob' } },
244+
})
245+
apiMocks.getTwistClient.mockResolvedValue(client)
246+
247+
const program = createProgram()
248+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
249+
250+
await program.parseAsync(['node', 'tw', 'thread', 'view', '500', '--unread', '--json'])
251+
252+
const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0])
253+
expect(jsonOutput.thread.id).toBe(500)
254+
expect(jsonOutput.comments).toHaveLength(0)
255+
256+
consoleSpy.mockRestore()
257+
})
258+
259+
it('filters comments in --ndjson output when --unread is set', async () => {
260+
const client = createClient({
261+
comments: [createComment(1, 1), createComment(2, 2), createComment(3, 3)],
262+
unreadThreads: [{ threadId: 500, channelId: 100, objIndex: 1, directMention: false }],
263+
users: { 1: { id: 1, name: 'Alice' }, 2: { id: 2, name: 'Bob' } },
264+
})
265+
apiMocks.getTwistClient.mockResolvedValue(client)
266+
267+
const program = createProgram()
268+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
269+
270+
await program.parseAsync(['node', 'tw', 'thread', 'view', '500', '--unread', '--ndjson'])
271+
272+
const lines = consoleSpy.mock.calls.map((c) => JSON.parse(c[0]))
273+
// First line is the thread
274+
expect(lines[0].type).toBe('thread')
275+
// Only unread comments (objIndex > 1)
276+
const commentLines = lines.filter((l) => l.type === 'comment')
277+
expect(commentLines).toHaveLength(2)
278+
expect(commentLines[0].id).toBe(2)
279+
expect(commentLines[1].id).toBe(3)
280+
281+
consoleSpy.mockRestore()
282+
})
283+
284+
it('returns all comments in --json without --unread', async () => {
285+
const client = createClient({
286+
comments: [createComment(1, 1), createComment(2, 2)],
287+
unreadThreads: [{ threadId: 500, channelId: 100, objIndex: 1, directMention: false }],
288+
users: { 1: { id: 1, name: 'Alice' }, 2: { id: 2, name: 'Bob' } },
289+
})
290+
apiMocks.getTwistClient.mockResolvedValue(client)
291+
292+
const program = createProgram()
293+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
294+
295+
await program.parseAsync(['node', 'tw', 'thread', 'view', '500', '--json'])
296+
297+
const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0])
298+
// Without --unread, all comments are returned
299+
expect(jsonOutput.comments).toHaveLength(2)
300+
// getUnread should not be called
301+
expect(client.threads.getUnread).not.toHaveBeenCalled()
302+
303+
consoleSpy.mockRestore()
304+
})
305+
})

src/commands/thread/view.ts

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -107,18 +107,37 @@ export async function viewThread(ref: string, options: ViewOptions): Promise<voi
107107

108108
await assertChannelIsPublic(thread.channelId, thread.workspaceId)
109109

110-
let lastReadObjIndex: number | null = null
110+
// Resolve unread state and filter comments before any output
111+
let displayComments = comments
112+
let contextComments: typeof comments = []
113+
let lastReadObjIndex = 0
114+
let hasUnread = false
115+
111116
if (options.unread) {
112117
const unreadData = await client.threads.getUnread(thread.workspaceId)
113118
const threadUnread = unreadData.find((u) => u.threadId === threadId)
114-
if (!threadUnread) {
115-
console.log('No unread comments in this thread.')
116-
return
119+
120+
if (threadUnread) {
121+
lastReadObjIndex = threadUnread.objIndex
122+
const contextSize = options.context ? parseInt(options.context, 10) : 0
123+
displayComments = comments.filter((c) => (c.objIndex ?? 0) > lastReadObjIndex)
124+
contextComments = comments
125+
.filter((c) => (c.objIndex ?? 0) <= lastReadObjIndex)
126+
.sort((a, b) => (b.objIndex ?? 0) - (a.objIndex ?? 0))
127+
.slice(0, contextSize)
128+
.reverse()
129+
hasUnread = displayComments.length > 0
130+
} else {
131+
displayComments = []
132+
hasUnread = false
117133
}
118-
lastReadObjIndex = threadUnread.objIndex
119134
}
120135

121-
const userIds = new Set<number>([thread.creator, ...comments.map((c) => c.creator)])
136+
const userIds = new Set<number>([
137+
thread.creator,
138+
...displayComments.map((c) => c.creator),
139+
...contextComments.map((c) => c.creator),
140+
])
122141
const userCalls = [...userIds].map((id) =>
123142
client.workspaceUsers.getUserById(
124143
{ workspaceId: thread.workspaceId, userId: id },
@@ -140,7 +159,7 @@ export async function viewThread(ref: string, options: ViewOptions): Promise<voi
140159
channelName: channel.name,
141160
creatorName: userMap.get(thread.creator),
142161
},
143-
comments: comments.map((c) => ({
162+
comments: displayComments.map((c) => ({
144163
...c,
145164
creatorName: userMap.get(c.creator),
146165
})),
@@ -157,7 +176,7 @@ export async function viewThread(ref: string, options: ViewOptions): Promise<voi
157176
creatorName: userMap.get(thread.creator),
158177
}
159178
console.log(JSON.stringify(threadOutput))
160-
for (const c of comments) {
179+
for (const c of displayComments) {
161180
console.log(
162181
JSON.stringify({ type: 'comment', ...c, creatorName: userMap.get(c.creator) }),
163182
)
@@ -169,27 +188,20 @@ export async function viewThread(ref: string, options: ViewOptions): Promise<voi
169188
console.log(colors.channel(`[${channel.name}]`))
170189
console.log('')
171190

172-
if (options.unread && lastReadObjIndex !== null) {
173-
const contextSize = options.context ? parseInt(options.context, 10) : 0
174-
const unreadComments = comments.filter((c) => (c.objIndex ?? 0) > lastReadObjIndex)
175-
const contextComments = comments
176-
.filter((c) => (c.objIndex ?? 0) <= lastReadObjIndex)
177-
.sort((a, b) => (b.objIndex ?? 0) - (a.objIndex ?? 0))
178-
.slice(0, contextSize)
179-
.reverse()
180-
181-
if (unreadComments.length === 0) {
182-
console.log('No unread comments.')
183-
return
184-
}
185-
191+
if (options.unread) {
186192
const creatorName = userMap.get(thread.creator) || `user:${thread.creator}`
187193
console.log(
188194
`${colors.author(creatorName)} ${colors.timestamp(formatRelativeDate(thread.posted))} ${chalk.dim('(original post)')}`,
189195
)
190196
console.log('')
191197
console.log(options.raw ? thread.content : renderMarkdown(thread.content))
192198

199+
if (!hasUnread) {
200+
console.log('')
201+
console.log('No unread comments.')
202+
return
203+
}
204+
193205
if (contextComments.length > 0) {
194206
const firstContextIndex = contextComments[0].objIndex ?? 0
195207
const skippedCount = firstContextIndex - 1
@@ -205,9 +217,9 @@ export async function viewThread(ref: string, options: ViewOptions): Promise<voi
205217
printSeparator(`${lastReadObjIndex} ${pluralize(lastReadObjIndex, 'comment')} skipped`)
206218
}
207219

208-
printSeparator(`UNREAD (${unreadComments.length} new)`)
220+
printSeparator(`UNREAD (${displayComments.length} new)`)
209221

210-
for (const comment of unreadComments) {
222+
for (const comment of displayComments) {
211223
printComment(comment, userMap, options.raw ?? false)
212224
}
213225
} else {

0 commit comments

Comments
 (0)