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.
- package/dist/cli.js +226 -2
- package/dist/extension.js +143 -1
- package/dist/mcp-server.js +140 -1
- package/dist/sdk/index.cjs +91 -1
- package/dist/sdk/index.mjs +91 -1
- package/dist/sdk/sdk/KanbanSDK.d.ts +38 -0
- package/dist/sdk/sdk/modules/comments.d.ts +33 -0
- package/dist/sdk/shared/types.d.ts +21 -0
- package/dist/standalone-webview/index.js +14 -14
- package/dist/standalone-webview/index.js.map +1 -1
- package/dist/standalone-webview/style.css +1 -1
- package/dist/standalone.js +143 -1
- package/package.json +1 -1
- package/src/cli/index.ts +33 -1
- package/src/mcp-server/index.ts +52 -0
- package/src/sdk/KanbanSDK.ts +51 -1
- package/src/sdk/modules/comments.ts +91 -0
- package/src/shared/types.ts +9 -0
- package/src/standalone/broadcastService.ts +40 -0
- package/src/standalone/internal/routes/tasks.ts +45 -1
- package/src/webview/App.tsx +31 -0
- package/src/webview/assets/main.css +46 -0
- package/src/webview/components/CardEditor.tsx +1 -1
- package/src/webview/components/CommentsSection.tsx +28 -20
package/dist/standalone.js
CHANGED
|
@@ -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
|
-
|
|
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
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
|
|
package/src/mcp-server/index.ts
CHANGED
|
@@ -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.',
|
package/src/sdk/KanbanSDK.ts
CHANGED
|
@@ -2224,7 +2224,57 @@ export class KanbanSDK {
|
|
|
2224
2224
|
return card
|
|
2225
2225
|
}
|
|
2226
2226
|
|
|
2227
|
-
|
|
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
|
*/
|
package/src/shared/types.ts
CHANGED
|
@@ -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 {
|
package/src/webview/App.tsx
CHANGED
|
@@ -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;
|