Conversation
Give `tdc` the inverses it lacked so a caller that archives or marks read can record an undo step. `thread undone` and `conversation undone` mirror their `done` siblings (`--yes`, `--dry-run`, `--json`). `thread mark-unread` mirrors `mark-read`, including bulk refs on stdin, and takes `--from <comment-ref>` to mark unread from one comment onward: the API's objIndex is the last comment that stays read, so a comment at index N is sent as N-1 (-1 marks the whole thread). The bulk-ref and unread-lookup helpers `mark-read` kept private move to thread/helpers.ts so both verbs share them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@doistbot /review |
|
doistbot
left a comment
There was a problem hiding this comment.
Nice follow-through on adding the missing inverses — thread undone, conversation undone, and thread mark-unread slot cleanly into the existing done/mark-read patterns, with the helper extraction into thread/helpers.ts making good reuse of the bulk-ref plumbing. A few things worth tightening:
markThreadUnreadduplicates the bulk-ref loop frommarkThreadRead(ref collection,--yesguard, preview/changed/unchanged branches, summary footer); consider extracting a shared driver parameterized by per-thread callbacks so the control flow can't drift.mark-unread --json --dry-runproduces no output for either output mode — emit the collectedjsonStatusesin dry-run (likereact) or reject the flag combo, and cover it with a test.conversation undone --yesalways fetches the conversation even though only the ID is used; branch on dry-run/--yesfirst so scripted calls skip the extra round trip and stale pre-check.- With
--from, the workspace-wide unread list is loaded before the comment ref is resolved, so an invalid comment triggers a potentially large request beforeINVALID_REF; resolve/validate--fromfirst.
I also included a few optional follow-up notes in the details below.
Optional follow-up notes (4)
src/commands/thread/mutate.ts:53: Use the new
threadLabel(thread)helper from./helpers.jsinstead of inlining${thread.title} (${threadId}). That is the same label format the shared read/unread paths now use;markThreadDoneabove duplicates it too.src/commands/thread/helpers.ts:136:
getLatestObjIndexis now exported fromthread/helpers.ts, but onlyread.tsimports it;unread.tsnever uses it. The project keepshelpers.tsfor utilities shared by multiple subcommands, so this function can stay private inread.tsinstead of widening the shared surface.src/commands/thread/thread.test.ts:2077: The bulk mark-unread tests assert counts but not which thread each ref maps to.
marks bulk refs unread with --yesonly checksmarkUnreadwas called twice, not{ id: '500', objIndex: -1 }and{ id: '501', objIndex: -1 }; the stdin preview test uses the defaultgetThreadmock (which returnsTest Thread (500)for every id) and asserts only oneWould mark unread...line. A ref-resolution regression that collapsed both refs to500would still pass. Mirror the mark-read bulk tests: mockgetThreadper id and assert the per-ref calls/labels.src/commands/thread/thread.test.ts:2061:
previews bulk refs from stdin and asks for --yesnever controlsprocess.stdin.isTTY. The command action returns early and prints help whenrefs.length === 0 && process.stdin.isTTY, so this test fails in a TTY test environment before exercisingmarkThreadUnread. The three mark-read stdin tests explicitly save and restoreprocess.stdin.isTTY = false; do the same here so the test's outcome doesn't depend on the runner's TTY state.
| const conversationId = resolveConversationId(ref) | ||
|
|
||
| const client = await getCommsClient() | ||
| const conversation = await client.conversations.getConversation(conversationId) |
There was a problem hiding this comment.
conversation undone <id> --yes always fetches the conversation before mutating, but the execution paths only use the ID and print a fixed status object/message. Branch on dryRun/yes first and fetch only for previews or confirmation, so scripted undo calls avoid an extra network round trip and a stale existence pre-check.
There was a problem hiding this comment.
Keeping as-is. It mirrors conversation done and thread done/undone, which all fetch first, and I'd rather the four stay one shape than have one skip the lookup. Happy to drop the fetch across all four in a follow-up if you think the round trip matters.
Address the doistbot pass on #64. The bulk-ref loop now lives once in thread/helpers.ts as runThreadReadStateMutation, with each verb supplying a per-thread plan; the plan runs before the unread lookup so a bad --from ref fails without a workspace-wide request. Both verbs now print their statuses under --json --dry-run instead of nothing (mark-read had the same gap). getLatestObjIndex goes back to being private to read.ts, done/undone use threadLabel, and the bulk tests assert per-ref calls with the stdin case pinning isTTY. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four optional notes: all done in 9959a58. |
|
@doistbot /review |
doistbot
left a comment
There was a problem hiding this comment.
Adds three inverse verbs — thread undone, conversation undone, and thread mark-unread (with --from <comment-ref>) — mirroring the existing done/mark-read commands, with the bulk-ref and unread-lookup helpers shared via thread/helpers.ts. Few things worth tightening:
- The
--fromsingle-thread guard only sees positional refs since it runs beforecollectThreadRefs, so stdin-supplied refs bypass theCONFLICTING_OPTIONScheck — the first thread gets marked unread before anINVALID_REFfailure on the second, leaving a partial mutation. Move the check to after ref collection (e.g. insiderunThreadReadStateMutation). - Add a test covering
--fromwith multiple refs piped via stdin, assertingCONFLICTING_OPTIONSand nomarkUnreadcalls, so the guard gap above is locked in once fixed.
I also included a few optional follow-up notes in the details below.
Optional follow-up notes (8)
src/commands/thread/mutate.ts:45:
markThreadUndonerepeatsmarkThreadDonealmost verbatim (resolve, fetch, public-channel check, dry-run,--yesguard, JSON/status output), differing only in verb, API call, and status field. The repo already models this exact pair withsetArchiveState(ref, options, archive)insrc/commands/channel/archive.ts. Extract an equivalentsetThreadArchiveStateand have both commands delegate to it witharchive: true/falseso the control flow can't drift.src/commands/conversation/undone.ts:7:
markConversationUndoneduplicatesmarkConversationDone(resolve, fetch, dry-run,--yes/MISSING_YES_FLAGguard, JSON/status output) with only the archive direction differing. Follow the existingsetArchiveStatepattern fromsrc/commands/channel/archive.tsby extracting a sharedsetConversationArchiveStateand delegating bothdoneandundoneto it. This keeps the current fetch-first order while removing the duplicated control flow.src/commands/thread/mutate.ts:67: This new line inlines
${thread.title}while the dry-run branch just above uses the sharedthreadLabel(thread)helper, andconversation undoneusesconversationLabel(conversation)at its equivalent line. UsethreadLabel(thread)here too so the confirmation preview carries the sameTitle (id)format as the other unarchive outputs.src/commands/thread/helpers.ts:144: Bulk
mark-read/mark-unreadruns each thread serially: every ref awaitsgetThread(plus the firstgetUnread/channel lookups) and then its mutation before the next ref starts. The per-thread calls are independent, so a large stdin batch costs ~2N sequential round trips. The codebase already parallelizes independent calls elsewhere (e.g.resolveChannelMemberRefs,channel/set); considerPromise.allover the refs once the shared per-workspace caches are warmed.src/commands/thread/helpers.ts:146:
mark-unread --fromresolves the comment here beforeloadThreadReadStatestarts itsgetThreadcall on the next line. Those lookups use already-known IDs and only need to be compared afterward, so each successful--frominvocation pays two sequential API round trips (then the unread lookup). Split thread loading so the comment lookup and initial thread fetch run in parallel, and defergetUnreaduntil comment validation succeeds to preserve the existing invalid-comment optimization.src/commands/thread/thread.test.ts:2163: This test duplicates
rejects a --from comment that belongs to another thread(line 1988): same fixture, same command, sameINVALID_REFassertion. Fold thegetUnreadnot-called assertion into that test and delete this one.src/commands/thread/thread.test.ts:2023: The name promises "at or before", but this only exercises
lastReadObjIndex === target(objIndex: 0vs target0). The< targethalf ofisUnchanged(e.g. a fully unread thread withobjIndex: -1plus--froma later comment) is untested, so changing<=to===would still pass. Extend the case so an already-earlier unread position is a no-op.src/commands/thread/unread.ts:47: The
unchangedJSON row'slastReadObjIndex: state.lastReadObjIndex ?? target(the thread's actual read position, not the requested target) is never asserted: both--jsontests use fully read threads, which take theplannedpath. A test likemark-unread 500 --from 11 --jsonagainst a thread already unread at objIndex 0 with a--fromtarget of 2 would lock the contract that scripts see the current state — and fail if the ternary is simplified to always returntarget.
The single-thread check ran on positional refs only, so refs piped on stdin bypassed it and the first thread was mutated before the second failed. The driver now takes a validateRefs hook that sees the merged list. Also from the second doistbot pass: done/undone share one setThreadArchiveState / setConversationArchiveState like the channel pair, the confirmation line uses the same Title (id) label as dry run, and the tests cover the earlier-unread no-op and the unchanged JSON row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The eight optional notes, in 92c38d3: Two kept as-is: bulk stays serial because a batch of mutations is easier to reason about when it stops at the first failure with everything before it applied in order, and |
|
@doistbot /review |
doistbot
left a comment
There was a problem hiding this comment.
This PR adds the three inverse verbs — thread undone, conversation undone, and thread mark-unread (with --from <comment-ref>) — and consolidates the previously duplicated mark-read/mark-unread control flow into a shared driver with setArchiveState helpers mirroring the existing channel pattern. No inline issues were flagged; prior review rounds' items (bulk-ref loop duplication, JSON dry-run output, --from validation ordering, the stdin single-thread guard, helper reuse, and test coverage) are all addressed in the current head, and the security and reuse passes found nothing new.
I also included a few optional follow-up notes in the details below.
Optional follow-up notes (4)
src/commands/thread/mutate.ts:44: In execute mode
undone --yesalways callsunarchiveThread, even whenthread.isArchivedis alreadyfalse— the same state the dry-run branch reports asalready in inbox. The channel archive helper skips the write viachannel.archived !== archive; mirror that here (and insetConversationArchiveState) so a repeated undo is a no-op instead of a redundant API call plus a misleadingunarchivedsuccess line.src/commands/thread/helpers.ts:144:
jsonStatusesandtextStatusesare both populated for every ref, but the output modes are exclusive: JSON mode returns before readingtextStatuses, while text mode never readsjsonStatuses. For large stdin batches this retains unnecessary per-ref state and constructs unused JSON status objects. Collect only the statuses for the selected output mode (or use a single outcome collection and materialize the selected representation).src/commands/thread/unread.ts:79: The
INVALID_REFguard for comments without a numericobjIndexhas no test. A regression here (e.g. dropping the guard) would sendNaN/undefinedtomarkUnreadinstead of failing fast. Add athread mark-unread 500 --from 11case wheregetCommentreturns a comment with noobjIndexand assert it rejects withINVALID_REFand never callsmarkUnread.src/commands/thread/thread.test.ts:1923: This dry-run test only covers an already-in-inbox thread (
Status: already in inbox). The normal preview path from the PR test plan —thread undone <archived-ref> --dry-run— should printWould unarchive threadwith the title and no status line, but that branch (thread.isArchived === true) is untested. Add an archived-thread dry-run case asserting the title and that noStatus:line is emitted.
Mirror the channel archive helper: skip the write when the thread or conversation is already where the verb would put it, and say so in the text output, so a repeated undo is idempotent. Also cover the archived dry-run preview and the missing-objIndex guard from the third doistbot pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Overview
tdc thread done,tdc conversation doneandtdc thread mark-readhad no inverses, so a caller that archives or marks read on someone's behalf (Bearing's comms "done" action is the one I have) cannot record an undo step. This adds the three:tdc thread undone <thread-ref>: unarchive, back to the inbox. Same shape asdone(--yes,--dry-run,--json).tdc conversation undone <conversation-ref>: same, for conversations.tdc thread mark-unread [thread-refs...] [--from <comment-ref>]: same shape asmark-read, bulk refs on stdin included. Without--fromthe whole thread goes unread. With it, that comment and everything after it does;--fromis single-thread only since a comment belongs to one thread.The one non-obvious bit is what
--fromsends. The SDK'smarkUnreadtakes the objIndex of the last comment that stays read (-1for none), so a comment at index N is sent as N-1. Probed live on a throwaway thread: aftermark-unread --from <comment at 1>,thread view --unreadshows comments 1 and 2 of 0..2.The bulk-ref and unread-lookup helpers
mark-readkept private moved tothread/helpers.tsso both verbs share them;mark-readbehaviour is unchanged and its tests pass as they were.Reference
n/a. Follow-up in Bearing once this is released: record
tdc thread undone <id> --yesas the undo step for its comms archive action.Changelog
Add
thread undone,conversation undoneandthread mark-unread(with--from <comment-ref>), the inverses ofdoneandmark-read.Test plan
Use a thread in a channel where you are the only member (a private one; add
--include-private-channels) and your self-DM, so nobody else sees the round trip.tdc thread done <ref> --yes, thentdc thread undone <ref> --dry-runWould unarchive threadwith the title, no status linetdc thread undone <ref> --yes --json, thentdc thread undone <ref> --dry-run{ id, isArchived: false }; the thread is back intdc inbox; the second dry run saysStatus: already in inboxtdc thread mark-read <ref>, thentdc thread mark-unread <ref> --from <second comment id>tdc thread view <ref> --unreadshows the second and third comments onlymark-unread --fromagain saysis already unread from comment ...and makes no calltdc thread mark-unread <ref>view --unreadnow shows all three;mark-unread <ref> <ref> --from xerrors withCONFLICTING_OPTIONStdc conversation done id:<self-dm> --yes, thentdc conversation undone id:<self-dm> --yes --json{ id, archived: false }andtdc conversation list --state archivedno longer lists it🤖 Generated with Claude Code