kanban-lite 1.2.13 → 1.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -56311,6 +56311,15 @@ async function broadcastLogsUpdatedToEditingClients(ctx, cardId, logs) {
56311
56311
  client.send(json2);
56312
56312
  }
56313
56313
  }
56314
+ function broadcastCommentStreamStart(ctx, cardId, commentId, author, created) {
56315
+ broadcast(ctx, { type: "commentStreamStart", cardId, commentId, author, created });
56316
+ }
56317
+ function broadcastCommentChunk(ctx, cardId, commentId, chunk) {
56318
+ broadcast(ctx, { type: "commentChunk", cardId, commentId, chunk });
56319
+ }
56320
+ function broadcastCommentStreamDone(ctx, cardId, commentId) {
56321
+ broadcast(ctx, { type: "commentStreamDone", cardId, commentId });
56322
+ }
56314
56323
 
56315
56324
  // src/standalone/watcherSetup.ts
56316
56325
  var fs5 = __toESM(require("fs"));
@@ -60550,6 +60559,56 @@ async function updateComment(ctx, { cardId, commentId, content, boardId }) {
60550
60559
  });
60551
60560
  return card;
60552
60561
  }
60562
+ async function streamComment(ctx, {
60563
+ cardId,
60564
+ author,
60565
+ boardId,
60566
+ stream,
60567
+ onStart,
60568
+ onChunk
60569
+ }) {
60570
+ if (!author?.trim())
60571
+ throw new Error("Comment author cannot be empty");
60572
+ const card = await ctx.getCard(cardId, boardId);
60573
+ if (!card)
60574
+ throw new Error(`Card not found: ${cardId}`);
60575
+ if (!card.comments)
60576
+ card.comments = [];
60577
+ const maxId = card.comments.reduce((max, c) => {
60578
+ const num = parseInt(c.id.replace("c", ""), 10);
60579
+ return Number.isNaN(num) ? max : Math.max(max, num);
60580
+ }, 0);
60581
+ const commentId = `c${maxId + 1}`;
60582
+ const created = (/* @__PURE__ */ new Date()).toISOString();
60583
+ onStart?.(commentId, author, created);
60584
+ let accumulated = "";
60585
+ for await (const chunk of stream) {
60586
+ accumulated += chunk;
60587
+ onChunk?.(commentId, chunk);
60588
+ }
60589
+ const comment = {
60590
+ id: commentId,
60591
+ author,
60592
+ created,
60593
+ content: accumulated
60594
+ };
60595
+ card.comments.push(comment);
60596
+ card.modified = (/* @__PURE__ */ new Date()).toISOString();
60597
+ await ctx._storage.writeCard(card);
60598
+ await appendActivityLog(ctx, {
60599
+ cardId: card.id,
60600
+ boardId: card.boardId || ctx._resolveBoardId(boardId),
60601
+ eventType: "comment.created",
60602
+ text: `Comment added by \`${author}\` (streamed)`,
60603
+ metadata: {
60604
+ commentId: comment.id,
60605
+ author,
60606
+ created: comment.created
60607
+ }
60608
+ }).catch(() => {
60609
+ });
60610
+ return card;
60611
+ }
60553
60612
  async function deleteComment(ctx, { cardId, commentId, boardId }) {
60554
60613
  const card = await ctx.getCard(cardId, boardId);
60555
60614
  if (!card)
@@ -62611,7 +62670,47 @@ var KanbanSDK = class _KanbanSDK {
62611
62670
  this._runAfterEvent("comment.deleted", { ...deletedComment, cardId: mergedInput.cardId }, void 0, card.boardId ?? this._resolveBoardId(mergedInput.boardId));
62612
62671
  return card;
62613
62672
  }
62614
- // --- Log management ---
62673
+ /**
62674
+ * Creates a comment on a card from a streaming text source, persisting it
62675
+ * once the stream is exhausted.
62676
+ *
62677
+ * This method is the streaming counterpart to {@link addComment}. It is
62678
+ * intended for use by AI agents that generate comment text incrementally
62679
+ * (e.g. an LLM `textStream`). The caller may supply `onStart` and `onChunk`
62680
+ * callbacks to fan live progress out to connected WebSocket viewers without
62681
+ * requiring intermediate disk writes.
62682
+ *
62683
+ * @param cardId - The ID of the card to comment on.
62684
+ * @param author - Display name of the streaming author.
62685
+ * @param stream - An `AsyncIterable<string>` that yields text chunks.
62686
+ * @param options.boardId - Optional board ID override.
62687
+ * @param options.onStart - Called once before iteration with the allocated
62688
+ * comment ID, author, and ISO timestamp.
62689
+ * @param options.onChunk - Called after each chunk with the comment ID and
62690
+ * the raw chunk string.
62691
+ * @returns A promise resolving to the updated {@link Card} once the stream
62692
+ * has been fully consumed and the comment has been persisted.
62693
+ * @throws {Error} If the card is not found.
62694
+ * @throws {Error} If `author` is empty.
62695
+ *
62696
+ * @example
62697
+ * ```ts
62698
+ * // Stream an AI SDK textStream as a comment
62699
+ * const { textStream } = await streamText({ model, prompt })
62700
+ * const card = await sdk.streamComment('42', 'ai-agent', textStream, {
62701
+ * onStart: (id, author, created) => broadcast({ type: 'commentStreamStart', cardId: '42', commentId: id, author, created }),
62702
+ * onChunk: (id, chunk) => broadcast({ type: 'commentChunk', cardId: '42', commentId: id, chunk }),
62703
+ * })
62704
+ * ```
62705
+ */
62706
+ async streamComment(cardId, author, stream, options) {
62707
+ const { boardId, onStart, onChunk } = options ?? {};
62708
+ const card = await streamComment(this, { cardId, author, boardId, stream, onStart, onChunk });
62709
+ const newComment = card.comments?.[card.comments.length - 1];
62710
+ if (newComment)
62711
+ this._runAfterEvent("comment.created", { ...newComment, cardId }, void 0, card.boardId ?? this._resolveBoardId(boardId));
62712
+ return card;
62713
+ }
62615
62714
  /**
62616
62715
  * Returns the absolute path to the log file for a card.
62617
62716
  *
@@ -64816,6 +64915,49 @@ async function handleTaskRoutes(request) {
64816
64915
  }
64817
64916
  return true;
64818
64917
  }
64918
+ params = route("POST", "/api/tasks/:id/comments/stream");
64919
+ if (params) {
64920
+ const { id } = params;
64921
+ const author = (url.searchParams.get("author") ?? req.headers["x-comment-author"] ?? "").trim();
64922
+ if (!author) {
64923
+ jsonError(res, 400, "author query param is required");
64924
+ return true;
64925
+ }
64926
+ let commentId;
64927
+ try {
64928
+ async function* requestTextStream() {
64929
+ const decoder = new TextDecoder("utf-8");
64930
+ for await (const chunk of req) {
64931
+ yield decoder.decode(chunk, { stream: true });
64932
+ }
64933
+ }
64934
+ const card = await runWithRequestAuth(
64935
+ () => ctx.sdk.streamComment(id, author, requestTextStream(), {
64936
+ boardId: url.searchParams.get("boardId") ?? void 0,
64937
+ onStart: (cid, commentAuthor, created) => {
64938
+ commentId = cid;
64939
+ broadcastCommentStreamStart(ctx, id, cid, commentAuthor, created);
64940
+ },
64941
+ onChunk: (cid, chunk) => {
64942
+ broadcastCommentChunk(ctx, id, cid, chunk);
64943
+ }
64944
+ })
64945
+ );
64946
+ if (!card) {
64947
+ jsonError(res, 404, "Task not found");
64948
+ return true;
64949
+ }
64950
+ await loadCards(ctx);
64951
+ broadcast(ctx, buildInitMessage(ctx));
64952
+ if (commentId)
64953
+ broadcastCommentStreamDone(ctx, id, commentId);
64954
+ const comment = card.comments?.find((c) => c.id === commentId);
64955
+ jsonOk(res, comment ?? null, 201);
64956
+ } catch (err) {
64957
+ handleKnownError(err);
64958
+ }
64959
+ return true;
64960
+ }
64819
64961
  params = route("PUT", "/api/tasks/:id/comments/:commentId");
64820
64962
  if (params) {
64821
64963
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanban-lite",
3
- "version": "1.2.13",
3
+ "version": "1.2.16",
4
4
  "description": "Kanban board manager - VSCode extension, CLI, MCP server, and standalone web app",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/cli/index.ts CHANGED
@@ -918,7 +918,7 @@ async function cmdComment(sdk: KanbanSDK, positional: string[], flags: Flags): P
918
918
  const subcommand = positional[0] || 'list'
919
919
  const boardId = getBoardId(flags)
920
920
 
921
- if (subcommand !== 'list' && subcommand !== 'add' && subcommand !== 'edit' && subcommand !== 'remove' && subcommand !== 'rm') {
921
+ if (subcommand !== 'list' && subcommand !== 'add' && subcommand !== 'edit' && subcommand !== 'remove' && subcommand !== 'rm' && subcommand !== 'stream') {
922
922
  // If first positional looks like a card ID, treat it as "list <cardId>"
923
923
  const resolvedId = await resolveCardId(sdk, subcommand, boardId)
924
924
  const comments = await sdk.listComments(resolvedId, boardId)
@@ -1026,6 +1026,37 @@ async function cmdComment(sdk: KanbanSDK, positional: string[], flags: Flags): P
1026
1026
  console.log(green(`Deleted comment ${commentId}`))
1027
1027
  break
1028
1028
  }
1029
+ case 'stream': {
1030
+ // Reads text from stdin and streams it as a comment in real-time.
1031
+ // Useful for piping LLM output: `llm-cli generate | kl comment stream <card-id> --author agent`
1032
+ if (!cardId) {
1033
+ console.error(red('Usage: kl comment stream <card-id> --author <name>'))
1034
+ process.exit(1)
1035
+ }
1036
+ const author = typeof flags.author === 'string' ? flags.author : ''
1037
+ if (!author) {
1038
+ console.error(red('Error: --author is required'))
1039
+ process.exit(1)
1040
+ }
1041
+ const resolvedId = await resolveCardId(sdk, cardId, boardId)
1042
+ async function* stdinStream(): AsyncIterable<string> {
1043
+ process.stdin.setEncoding('utf8')
1044
+ for await (const chunk of process.stdin) {
1045
+ if (!flags.json) process.stderr.write('.')
1046
+ yield chunk as string
1047
+ }
1048
+ }
1049
+ if (!flags.json) process.stderr.write('Streaming comment')
1050
+ const card = await sdk.streamComment(resolvedId, author, stdinStream(), { boardId })
1051
+ if (!flags.json) process.stderr.write('\n')
1052
+ const added = card.comments?.[card.comments.length - 1]
1053
+ if (flags.json) {
1054
+ console.log(JSON.stringify(added, null, 2))
1055
+ } else {
1056
+ console.log(green(`Streamed comment ${added?.id ?? '?'} to card ${resolvedId}`))
1057
+ }
1058
+ break
1059
+ }
1029
1060
  }
1030
1061
  }
1031
1062
 
@@ -1585,6 +1616,7 @@ ${bold('Attachment Commands:')}
1585
1616
  ${bold('Comment Commands:')}
1586
1617
  comment <id> List comments on a card
1587
1618
  comment add <id> Add a comment (--author, --body)
1619
+ comment stream <id> Stream a comment from stdin (--author)
1588
1620
  comment edit <id> <cid> Edit a comment (--body)
1589
1621
  comment remove <id> <cid> Remove a comment
1590
1622
 
@@ -1092,6 +1092,58 @@ async function main(): Promise<void> {
1092
1092
  }
1093
1093
  )
1094
1094
 
1095
+ server.tool(
1096
+ 'stream_comment',
1097
+ 'Add a comment to a kanban card from a streaming text source. Provide the full content string; it will be written via the streaming path so connected webview clients see it arrive incrementally.',
1098
+ {
1099
+ boardId: z.string().optional().describe('Board ID (uses default board if omitted)'),
1100
+ cardId: z.string().describe('Card ID (or partial ID)'),
1101
+ author: z.string().describe('Comment author name'),
1102
+ content: z.string().describe('Full comment text (supports markdown). The content is streamed word-by-word to connected viewers.'),
1103
+ },
1104
+ async ({ boardId, cardId, author, content }) => {
1105
+ let resolvedId = cardId
1106
+ const card = await sdk.getCard(cardId, boardId)
1107
+ if (!card) {
1108
+ const all = await sdk.listCards(undefined, boardId)
1109
+ const matches = all.filter(c => c.id.includes(cardId))
1110
+ if (matches.length === 1) {
1111
+ resolvedId = matches[0].id
1112
+ } else if (matches.length > 1) {
1113
+ return {
1114
+ content: [{ type: 'text' as const, text: `Multiple cards match "${cardId}": ${matches.map(m => m.id).join(', ')}` }],
1115
+ isError: true,
1116
+ }
1117
+ } else {
1118
+ return {
1119
+ content: [{ type: 'text' as const, text: `Card not found: ${cardId}` }],
1120
+ isError: true,
1121
+ }
1122
+ }
1123
+ }
1124
+
1125
+ // Wrap the full content string as an async iterable so it exercises the
1126
+ // same SDK streaming code path that a real token stream would use.
1127
+ async function* singleChunk(): AsyncIterable<string> { yield content }
1128
+
1129
+ try {
1130
+ const updated = await runWithMcpAuth(() => sdk.streamComment(resolvedId, author, singleChunk(), { boardId }))
1131
+ const added = updated.comments?.[updated.comments.length - 1]
1132
+ return {
1133
+ content: [{
1134
+ type: 'text' as const,
1135
+ text: JSON.stringify(added, null, 2),
1136
+ }],
1137
+ }
1138
+ } catch (err) {
1139
+ return {
1140
+ content: [{ type: 'text' as const, text: String(err) }],
1141
+ isError: true,
1142
+ }
1143
+ }
1144
+ }
1145
+ )
1146
+
1095
1147
  server.tool(
1096
1148
  'update_comment',
1097
1149
  'Update the content of a comment on a kanban card.',
@@ -2224,7 +2224,57 @@ export class KanbanSDK {
2224
2224
  return card
2225
2225
  }
2226
2226
 
2227
- // --- Log management ---
2227
+ /**
2228
+ * Creates a comment on a card from a streaming text source, persisting it
2229
+ * once the stream is exhausted.
2230
+ *
2231
+ * This method is the streaming counterpart to {@link addComment}. It is
2232
+ * intended for use by AI agents that generate comment text incrementally
2233
+ * (e.g. an LLM `textStream`). The caller may supply `onStart` and `onChunk`
2234
+ * callbacks to fan live progress out to connected WebSocket viewers without
2235
+ * requiring intermediate disk writes.
2236
+ *
2237
+ * @param cardId - The ID of the card to comment on.
2238
+ * @param author - Display name of the streaming author.
2239
+ * @param stream - An `AsyncIterable<string>` that yields text chunks.
2240
+ * @param options.boardId - Optional board ID override.
2241
+ * @param options.onStart - Called once before iteration with the allocated
2242
+ * comment ID, author, and ISO timestamp.
2243
+ * @param options.onChunk - Called after each chunk with the comment ID and
2244
+ * the raw chunk string.
2245
+ * @returns A promise resolving to the updated {@link Card} once the stream
2246
+ * has been fully consumed and the comment has been persisted.
2247
+ * @throws {Error} If the card is not found.
2248
+ * @throws {Error} If `author` is empty.
2249
+ *
2250
+ * @example
2251
+ * ```ts
2252
+ * // Stream an AI SDK textStream as a comment
2253
+ * const { textStream } = await streamText({ model, prompt })
2254
+ * const card = await sdk.streamComment('42', 'ai-agent', textStream, {
2255
+ * onStart: (id, author, created) => broadcast({ type: 'commentStreamStart', cardId: '42', commentId: id, author, created }),
2256
+ * onChunk: (id, chunk) => broadcast({ type: 'commentChunk', cardId: '42', commentId: id, chunk }),
2257
+ * })
2258
+ * ```
2259
+ */
2260
+ async streamComment(
2261
+ cardId: string,
2262
+ author: string,
2263
+ stream: AsyncIterable<string>,
2264
+ options?: {
2265
+ boardId?: string
2266
+ onStart?: (commentId: string, author: string, created: string) => void
2267
+ onChunk?: (commentId: string, chunk: string) => void
2268
+ }
2269
+ ): Promise<Card> {
2270
+ const { boardId, onStart, onChunk } = options ?? {}
2271
+ const card = await Comments.streamComment(this, { cardId, author, boardId, stream, onStart, onChunk })
2272
+ const newComment = card.comments?.[card.comments.length - 1]
2273
+ if (newComment) this._runAfterEvent('comment.created', { ...newComment, cardId }, undefined, card.boardId ?? this._resolveBoardId(boardId))
2274
+ return card
2275
+ }
2276
+
2277
+
2228
2278
 
2229
2279
  /**
2230
2280
  * Returns the absolute path to the log file for a card.
@@ -89,6 +89,97 @@ export async function updateComment(
89
89
  return card
90
90
  }
91
91
 
92
+ /**
93
+ * Creates a comment on a card from a streaming text source.
94
+ *
95
+ * Allocates a comment ID immediately, then reads text chunks from the given
96
+ * `AsyncIterable<string>`. After each chunk the optional `onChunk` callback is
97
+ * invoked so callers can broadcast partial content in real-time. When the
98
+ * iterable is exhausted the complete comment is persisted to storage.
99
+ *
100
+ * @param ctx - SDK context.
101
+ * @param options.cardId - ID of the card to comment on.
102
+ * @param options.author - Display name of the author.
103
+ * @param options.stream - Async iterable that yields text chunks.
104
+ * @param options.boardId - Optional board ID override.
105
+ * @param options.onStart - Called once before iteration with the allocated
106
+ * comment ID and author so the caller can broadcast a stream-start event.
107
+ * @param options.onChunk - Called for each chunk with the comment ID and the
108
+ * raw chunk string so the caller can broadcast partial content.
109
+ * @returns The updated card after the comment has been persisted.
110
+ *
111
+ * @example
112
+ * // Stream an AI response as a comment
113
+ * await sdk.streamComment('42', 'agent', aiTextStream, {
114
+ * onChunk: (commentId, chunk) => broadcastCommentChunk(ctx, cardId, commentId, chunk),
115
+ * })
116
+ */
117
+ export async function streamComment(
118
+ ctx: SDKContext,
119
+ {
120
+ cardId,
121
+ author,
122
+ boardId,
123
+ stream,
124
+ onStart,
125
+ onChunk,
126
+ }: {
127
+ cardId: string
128
+ author: string
129
+ boardId?: string
130
+ stream: AsyncIterable<string>
131
+ onStart?: (commentId: string, author: string, created: string) => void
132
+ onChunk?: (commentId: string, chunk: string) => void
133
+ }
134
+ ): Promise<Card> {
135
+ if (!author?.trim()) throw new Error('Comment author cannot be empty')
136
+
137
+ const card = await ctx.getCard(cardId, boardId)
138
+ if (!card) throw new Error(`Card not found: ${cardId}`)
139
+
140
+ if (!card.comments) card.comments = []
141
+
142
+ const maxId = card.comments.reduce((max, c) => {
143
+ const num = parseInt(c.id.replace('c', ''), 10)
144
+ return Number.isNaN(num) ? max : Math.max(max, num)
145
+ }, 0)
146
+
147
+ const commentId = `c${maxId + 1}`
148
+ const created = new Date().toISOString()
149
+
150
+ onStart?.(commentId, author, created)
151
+
152
+ let accumulated = ''
153
+ for await (const chunk of stream) {
154
+ accumulated += chunk
155
+ onChunk?.(commentId, chunk)
156
+ }
157
+
158
+ const comment: Comment = {
159
+ id: commentId,
160
+ author,
161
+ created,
162
+ content: accumulated,
163
+ }
164
+
165
+ card.comments.push(comment)
166
+ card.modified = new Date().toISOString()
167
+ await ctx._storage.writeCard(card)
168
+ await appendActivityLog(ctx, {
169
+ cardId: card.id,
170
+ boardId: card.boardId || ctx._resolveBoardId(boardId),
171
+ eventType: 'comment.created',
172
+ text: `Comment added by \`${author}\` (streamed)`,
173
+ metadata: {
174
+ commentId: comment.id,
175
+ author,
176
+ created: comment.created,
177
+ },
178
+ }).catch(() => {})
179
+
180
+ return card
181
+ }
182
+
92
183
  /**
93
184
  * Deletes a comment from a card.
94
185
  */
@@ -112,6 +112,12 @@ export interface Comment {
112
112
  created: string
113
113
  /** Markdown body of the comment. */
114
114
  content: string
115
+ /**
116
+ * When `true`, the comment is currently being streamed by an agent and has
117
+ * not yet been fully written. The content field contains whatever has
118
+ * accumulated so far. This field is stripped before persisting to storage.
119
+ */
120
+ streaming?: boolean
115
121
  }
116
122
 
117
123
  /**
@@ -759,6 +765,9 @@ export type ExtensionMessage =
759
765
  | { type: 'submitFormResult'; callbackKey: string; result?: SubmitFormTransportResult; error?: string }
760
766
  | { type: 'logsUpdated'; cardId: string; logs: import('./types').LogEntry[] }
761
767
  | { type: 'boardLogsUpdated'; boardId: string; logs: import('./types').LogEntry[] }
768
+ | { type: 'commentStreamStart'; cardId: string; commentId: string; author: string; created: string }
769
+ | { type: 'commentChunk'; cardId: string; commentId: string; chunk: string }
770
+ | { type: 'commentStreamDone'; cardId: string; commentId: string }
762
771
 
763
772
  export type WebviewMessage =
764
773
  | { type: 'ready' }
@@ -112,3 +112,43 @@ export async function broadcastLogsUpdatedToEditingClients(ctx: StandaloneContex
112
112
  client.send(json)
113
113
  }
114
114
  }
115
+
116
+ /**
117
+ * Broadcasts a `commentStreamStart` event to ALL connected WebSocket clients.
118
+ * Called once when a streaming comment session begins, before any chunks arrive.
119
+ */
120
+ export function broadcastCommentStreamStart(
121
+ ctx: StandaloneContext,
122
+ cardId: string,
123
+ commentId: string,
124
+ author: string,
125
+ created: string
126
+ ): void {
127
+ broadcast(ctx, { type: 'commentStreamStart', cardId, commentId, author, created })
128
+ }
129
+
130
+ /**
131
+ * Broadcasts a `commentChunk` event to ALL connected WebSocket clients.
132
+ * Called for every text chunk received during a streaming comment session.
133
+ */
134
+ export function broadcastCommentChunk(
135
+ ctx: StandaloneContext,
136
+ cardId: string,
137
+ commentId: string,
138
+ chunk: string
139
+ ): void {
140
+ broadcast(ctx, { type: 'commentChunk', cardId, commentId, chunk })
141
+ }
142
+
143
+ /**
144
+ * Broadcasts a `commentStreamDone` event to ALL connected WebSocket clients.
145
+ * Called once after the stream has been fully consumed and persisted.
146
+ */
147
+ export function broadcastCommentStreamDone(
148
+ ctx: StandaloneContext,
149
+ cardId: string,
150
+ commentId: string
151
+ ): void {
152
+ broadcast(ctx, { type: 'commentStreamDone', cardId, commentId })
153
+ }
154
+
@@ -3,7 +3,7 @@ import type { Card, CreateCardPayload, Priority } from '../../../shared/types'
3
3
  import type { CardStateCursor } from '../../../sdk/plugins'
4
4
  import { sanitizeCard, AuthError } from '../../../sdk/types'
5
5
  import { authErrorToHttpStatus, extractAuthContext, getCardStateErrorLike } from '../../authUtils'
6
- import { broadcast, broadcastCardContentToEditingClients, broadcastLogsUpdatedToEditingClients, buildInitMessage, loadCards } from '../../broadcastService'
6
+ import { broadcast, broadcastCardContentToEditingClients, broadcastCommentStreamStart, broadcastCommentChunk, broadcastCommentStreamDone, broadcastLogsUpdatedToEditingClients, buildInitMessage, loadCards } from '../../broadcastService'
7
7
  import { getListCardsOptions, getSubmitErrorStatus, parseSubmitData } from '../../cardHelpers'
8
8
  import {
9
9
  doAddAttachment,
@@ -398,6 +398,50 @@ export async function handleTaskRoutes(request: StandaloneRequestContext): Promi
398
398
  return true
399
399
  }
400
400
 
401
+ params = route('POST', '/api/tasks/:id/comments/stream')
402
+ if (params) {
403
+ const { id } = params
404
+ const author = (url.searchParams.get('author') ?? (req.headers['x-comment-author'] as string | undefined) ?? '').trim()
405
+ if (!author) {
406
+ jsonError(res, 400, 'author query param is required')
407
+ return true
408
+ }
409
+ let commentId: string | undefined
410
+ try {
411
+ // Convert the Node.js IncomingMessage readable into an AsyncIterable<string>
412
+ async function* requestTextStream(): AsyncIterable<string> {
413
+ const decoder = new TextDecoder('utf-8')
414
+ for await (const chunk of req as unknown as AsyncIterable<Buffer>) {
415
+ yield decoder.decode(chunk as Buffer, { stream: true })
416
+ }
417
+ }
418
+ const card = await runWithRequestAuth(() =>
419
+ ctx.sdk.streamComment(id, author, requestTextStream(), {
420
+ boardId: url.searchParams.get('boardId') ?? undefined,
421
+ onStart: (cid, commentAuthor, created) => {
422
+ commentId = cid
423
+ broadcastCommentStreamStart(ctx, id, cid, commentAuthor, created)
424
+ },
425
+ onChunk: (cid, chunk) => {
426
+ broadcastCommentChunk(ctx, id, cid, chunk)
427
+ },
428
+ })
429
+ )
430
+ if (!card) {
431
+ jsonError(res, 404, 'Task not found')
432
+ return true
433
+ }
434
+ await loadCards(ctx)
435
+ broadcast(ctx, buildInitMessage(ctx))
436
+ if (commentId) broadcastCommentStreamDone(ctx, id, commentId)
437
+ const comment = card.comments?.find(c => c.id === commentId)
438
+ jsonOk(res, comment ?? null, 201)
439
+ } catch (err) {
440
+ handleKnownError(err)
441
+ }
442
+ return true
443
+ }
444
+
401
445
  params = route('PUT', '/api/tasks/:id/comments/:commentId')
402
446
  if (params) {
403
447
  try {
@@ -467,6 +467,37 @@ function App(): React.JSX.Element {
467
467
  setBoardLogs(message.logs)
468
468
  break
469
469
  }
470
+ case 'commentStreamStart': {
471
+ // An agent has started streaming a comment — add a streaming placeholder
472
+ setEditingCard(prev => {
473
+ if (!prev || prev.id !== message.cardId) return prev
474
+ const placeholder = { id: message.commentId, author: message.author, created: message.created, content: '', streaming: true }
475
+ return { ...prev, comments: [...(prev.comments || []), placeholder] }
476
+ })
477
+ break
478
+ }
479
+ case 'commentChunk': {
480
+ // Append an incoming text chunk to the streaming comment
481
+ setEditingCard(prev => {
482
+ if (!prev || prev.id !== message.cardId) return prev
483
+ const comments = (prev.comments || []).map(c =>
484
+ c.id === message.commentId ? { ...c, content: c.content + message.chunk } : c
485
+ )
486
+ return { ...prev, comments }
487
+ })
488
+ break
489
+ }
490
+ case 'commentStreamDone': {
491
+ // Mark the streaming comment as complete (strip the streaming flag)
492
+ setEditingCard(prev => {
493
+ if (!prev || prev.id !== message.cardId) return prev
494
+ const comments = (prev.comments || []).map(c =>
495
+ c.id === message.commentId ? { ...c, streaming: false } : c
496
+ )
497
+ return { ...prev, comments }
498
+ })
499
+ break
500
+ }
470
501
  }
471
502
  }
472
503
 
@@ -419,6 +419,7 @@ body.vscode-high-contrast {
419
419
  padding: clamp(12px, 1.4vw, 16px);
420
420
  border-radius: 14px;
421
421
  background: var(--card-view-hero-bg);
422
+ z-index: 10;
422
423
  }
423
424
 
424
425
  .card-editor-hero-copy {
@@ -802,6 +803,10 @@ body.vscode-high-contrast {
802
803
  position: fixed;
803
804
  inset: 0;
804
805
  z-index: 60;
806
+ background: transparent;
807
+ border: none;
808
+ cursor: default;
809
+ padding: 0;
805
810
  }
806
811
 
807
812
  .card-floating-menu {
@@ -1095,6 +1100,47 @@ body.vscode-high-contrast {
1095
1100
  align-items: flex-start;
1096
1101
  }
1097
1102
 
1103
+ /* Streaming comment — subtle animated border to indicate live content */
1104
+ .card-comment-streaming .card-comment-bubble {
1105
+ border-color: var(--vscode-focusBorder, #007fd4);
1106
+ opacity: 0.92;
1107
+ }
1108
+
1109
+ .card-comment-streaming-badge {
1110
+ display: inline-flex;
1111
+ align-items: center;
1112
+ font-size: 0.65rem;
1113
+ font-weight: 600;
1114
+ letter-spacing: 0.04em;
1115
+ text-transform: uppercase;
1116
+ color: var(--vscode-focusBorder, #007fd4);
1117
+ padding: 1px 5px;
1118
+ border-radius: 4px;
1119
+ border: 1px solid var(--vscode-focusBorder, #007fd4);
1120
+ opacity: 0.85;
1121
+ }
1122
+
1123
+ /* Blinking cursor appended at the end of a streaming comment body */
1124
+ .card-comment-cursor {
1125
+ display: inline-block;
1126
+ width: 2px;
1127
+ height: 1em;
1128
+ vertical-align: text-bottom;
1129
+ background: currentColor;
1130
+ margin-left: 1px;
1131
+ animation: comment-blink 1s step-end infinite;
1132
+ }
1133
+
1134
+ @keyframes comment-blink {
1135
+ 0%, 100% { opacity: 1; }
1136
+ 50% { opacity: 0; }
1137
+ }
1138
+
1139
+ .card-comment-content-wrap {
1140
+ display: inline;
1141
+ }
1142
+
1143
+
1098
1144
  .card-comment-avatar {
1099
1145
  display: inline-flex;
1100
1146
  align-items: center;