From 1e875701c885388cdc985958bb843f7726fe619d Mon Sep 17 00:00:00 2001
From: Stefan Ernst <24554551+stefan-ernst@users.noreply.github.com>
Date: Wed, 21 Oct 2026 10:47:23 +0200
Subject: [PATCH 1/3] feat(comments): react to comments with emoji (#362)
Lets users acknowledge a comment (e.g. after an @mention) without writing
a reply.
- comment_reactions persistence: migration plus fresh-install schema for
SQLite and PostgreSQL, unique per comment, user, and reaction; rows
cascade with the comment and the user.
- Fixed reaction set: thumbs_up, check, eyes, heart, laugh, hooray.
- API v2: POST /comments/{comment_id}/reactions and
DELETE /comments/{comment_id}/reactions/{reaction}, both idempotent and
gated like commenting (comment read + item.comment). Comment list, get,
update, and batch responses carry grouped reactions; approval rows never do.
- Live update: reactions publish an item change so open views reconcile.
- UI: reaction chips under each comment (own reaction highlighted, click
toggles, tooltip lists who reacted) and a picker; strings for en, de, fr,
ru, zh-CN.
Contributed by @dpb97 in #362.
---
api/openapi-v2.json | 395 ++++++++++++++++++
frontend/src/lib/api/misc.js | 10 +
.../features/items/CommentReactions.svelte | 158 +++++++
.../src/lib/features/items/Comments.svelte | 15 +
frontend/src/lib/locales/de/workspace.js | 9 +
frontend/src/lib/locales/en/workspace.js | 9 +
frontend/src/lib/locales/fr/workspace.js | 9 +
frontend/src/lib/locales/ru/workspace.js | 2 +-
frontend/src/lib/locales/zh-CN/workspace.js | 9 +
internal/database/migrations.go | 32 ++
internal/database/schema/content.sql | 14 +
internal/database/schema/content_postgres.sql | 14 +
internal/models/item.go | 12 +
internal/restapi/v2/comments.go | 104 ++++-
internal/restapi/v2/contract-metadata.json | 77 ++++
internal/restapi/v2/router.go | 3 +
internal/services/comment_reactions.go | 162 +++++++
17 files changed, 1028 insertions(+), 6 deletions(-)
create mode 100644 frontend/src/lib/features/items/CommentReactions.svelte
create mode 100644 internal/services/comment_reactions.go
diff --git a/api/openapi-v2.json b/api/openapi-v2.json
index 5b4907884..6d4b5ddd1 100644
--- a/api/openapi-v2.json
+++ b/api/openapi-v2.json
@@ -7058,6 +7058,44 @@
"nullable": true,
"type": "integer"
},
+ "reactions": {
+ "description": "The reactions value.",
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "count": {
+ "description": "The count value.",
+ "type": "integer"
+ },
+ "reaction": {
+ "description": "The reaction value.",
+ "type": "string"
+ },
+ "user_ids": {
+ "description": "The user ids value.",
+ "items": {
+ "type": "integer"
+ },
+ "type": "array"
+ },
+ "user_names": {
+ "description": "The user names value.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "count",
+ "reaction",
+ "user_ids",
+ "user_names"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
"source": {
"description": "The source value.",
"type": "string"
@@ -13571,6 +13609,44 @@
"nullable": true,
"type": "integer"
},
+ "reactions": {
+ "description": "The reactions value.",
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "count": {
+ "description": "The count value.",
+ "type": "integer"
+ },
+ "reaction": {
+ "description": "The reaction value.",
+ "type": "string"
+ },
+ "user_ids": {
+ "description": "The user ids value.",
+ "items": {
+ "type": "integer"
+ },
+ "type": "array"
+ },
+ "user_names": {
+ "description": "The user names value.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "count",
+ "reaction",
+ "user_ids",
+ "user_names"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
"source": {
"description": "The source value.",
"type": "string"
@@ -53530,6 +53606,44 @@
"nullable": true,
"type": "integer"
},
+ "reactions": {
+ "description": "The reactions value.",
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "count": {
+ "description": "The count value.",
+ "type": "integer"
+ },
+ "reaction": {
+ "description": "The reaction value.",
+ "type": "string"
+ },
+ "user_ids": {
+ "description": "The user ids value.",
+ "items": {
+ "type": "integer"
+ },
+ "type": "array"
+ },
+ "user_names": {
+ "description": "The user names value.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "count",
+ "reaction",
+ "user_ids",
+ "user_names"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
"source": {
"description": "The source value.",
"type": "string"
@@ -65596,6 +65710,44 @@
"nullable": true,
"type": "integer"
},
+ "reactions": {
+ "description": "The reactions value.",
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "count": {
+ "description": "The count value.",
+ "type": "integer"
+ },
+ "reaction": {
+ "description": "The reaction value.",
+ "type": "string"
+ },
+ "user_ids": {
+ "description": "The user ids value.",
+ "items": {
+ "type": "integer"
+ },
+ "type": "array"
+ },
+ "user_names": {
+ "description": "The user names value.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "count",
+ "reaction",
+ "user_ids",
+ "user_names"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
"source": {
"description": "The source value.",
"type": "string"
@@ -65643,6 +65795,65 @@
],
"type": "object"
},
+ "PostCommentsCommentIdReactionsPayload": {
+ "additionalProperties": false,
+ "properties": {
+ "reaction": {
+ "description": "The reaction value.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "reaction"
+ ],
+ "type": "object"
+ },
+ "PostCommentsCommentIdReactionsResult": {
+ "additionalProperties": false,
+ "properties": {
+ "data": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "count": {
+ "description": "The count value.",
+ "type": "integer"
+ },
+ "reaction": {
+ "description": "The reaction value.",
+ "type": "string"
+ },
+ "user_ids": {
+ "description": "The user ids value.",
+ "items": {
+ "type": "integer"
+ },
+ "type": "array"
+ },
+ "user_names": {
+ "description": "The user names value.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "count",
+ "reaction",
+ "user_ids",
+ "user_names"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "data"
+ ],
+ "type": "object"
+ },
"PostConditionSetsPayload": {
"additionalProperties": false,
"properties": {
@@ -76012,6 +76223,44 @@
"nullable": true,
"type": "integer"
},
+ "reactions": {
+ "description": "The reactions value.",
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "count": {
+ "description": "The count value.",
+ "type": "integer"
+ },
+ "reaction": {
+ "description": "The reaction value.",
+ "type": "string"
+ },
+ "user_ids": {
+ "description": "The user ids value.",
+ "items": {
+ "type": "integer"
+ },
+ "type": "array"
+ },
+ "user_names": {
+ "description": "The user names value.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "count",
+ "reaction",
+ "user_ids",
+ "user_names"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
"source": {
"description": "The source value.",
"type": "string"
@@ -103670,6 +103919,152 @@
]
}
},
+ "/comments/{comment_id}/reactions": {
+ "post": {
+ "description": "Add comment reaction. The server validates resource ownership and command preconditions before persisting changes. Unknown JSON fields are rejected when the operation accepts a body.",
+ "operationId": "postCommentsCommentIdReactions",
+ "parameters": [
+ {
+ "description": "The comment identifier.",
+ "in": "path",
+ "name": "comment_id",
+ "required": true,
+ "schema": {
+ "minimum": 1,
+ "type": "integer"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PostCommentsCommentIdReactionsPayload"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PostCommentsCommentIdReactionsResult"
+ }
+ }
+ },
+ "description": "Add comment reaction response."
+ },
+ "400": {
+ "$ref": "#/components/responses/InvalidRequestError"
+ },
+ "401": {
+ "$ref": "#/components/responses/AuthenticationError"
+ },
+ "403": {
+ "$ref": "#/components/responses/PermissionError"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFoundError"
+ },
+ "413": {
+ "$ref": "#/components/responses/PayloadTooLargeError"
+ },
+ "415": {
+ "$ref": "#/components/responses/UnsupportedMediaTypeError"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimitError"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ },
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "summary": "Add comment reaction",
+ "tags": [
+ "Work items"
+ ],
+ "x-required-scopes": [
+ "items:write"
+ ]
+ }
+ },
+ "/comments/{comment_id}/reactions/{reaction}": {
+ "delete": {
+ "description": "Remove comment reaction. The server verifies resource ownership and deletion preconditions before removing the resource.",
+ "operationId": "deleteCommentsCommentIdReactionsReaction",
+ "parameters": [
+ {
+ "description": "The comment identifier.",
+ "in": "path",
+ "name": "comment_id",
+ "required": true,
+ "schema": {
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ {
+ "description": "The reaction key: thumbs_up, check, eyes, heart, laugh, or hooray.",
+ "in": "path",
+ "name": "reaction",
+ "required": true,
+ "schema": {
+ "enum": [
+ "thumbs_up",
+ "check",
+ "eyes",
+ "heart",
+ "laugh",
+ "hooray"
+ ],
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Request completed successfully with no response body."
+ },
+ "400": {
+ "$ref": "#/components/responses/InvalidRequestError"
+ },
+ "401": {
+ "$ref": "#/components/responses/AuthenticationError"
+ },
+ "403": {
+ "$ref": "#/components/responses/PermissionError"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFoundError"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimitError"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ },
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "summary": "Remove comment reaction",
+ "tags": [
+ "Work items"
+ ],
+ "x-required-scopes": [
+ "items:write"
+ ]
+ }
+ },
"/condition-sets": {
"get": {
"description": "List condition sets. Results include only resources visible to the authenticated caller; collections use their documented stable order and bounds.",
diff --git a/frontend/src/lib/api/misc.js b/frontend/src/lib/api/misc.js
index 58bc52046..c81298d47 100644
--- a/frontend/src/lib/api/misc.js
+++ b/frontend/src/lib/api/misc.js
@@ -83,6 +83,16 @@ export const deleteComment = (commentId) =>
fetchV2Data(`/comments/${commentId}`, {
method: 'DELETE',
});
+// Comment reactions (core#298): add returns the comment's grouped reactions.
+export const addCommentReaction = (commentId, reaction) =>
+ fetchV2Data(`/comments/${commentId}/reactions`, {
+ method: 'POST',
+ body: JSON.stringify({ reaction }),
+ });
+export const removeCommentReaction = (commentId, reaction) =>
+ fetchV2Data(`/comments/${commentId}/reactions/${encodeURIComponent(reaction)}`, {
+ method: 'DELETE',
+ });
// Attachments
export const attachments = {
diff --git a/frontend/src/lib/features/items/CommentReactions.svelte b/frontend/src/lib/features/items/CommentReactions.svelte
new file mode 100644
index 000000000..5e1e7a1b4
--- /dev/null
+++ b/frontend/src/lib/features/items/CommentReactions.svelte
@@ -0,0 +1,158 @@
+
+
+{#if reactions.length > 0 || canReact}
+
+ {#each reactions as group (group.reaction)}
+
+
+
+ {/each}
+ {#if canReact}
+
+ {#if pickerOpen}
+
+ {#each REACTIONS as reaction (reaction.key)}
+
+ {/each}
+
+ {/if}
+ {/if}
+
+{/if}
diff --git a/frontend/src/lib/features/items/Comments.svelte b/frontend/src/lib/features/items/Comments.svelte
index 2dcd92074..63f28698f 100644
--- a/frontend/src/lib/features/items/Comments.svelte
+++ b/frontend/src/lib/features/items/Comments.svelte
@@ -22,6 +22,7 @@
import { isExpectedBackgroundSyncError } from '../../utils/backgroundSync.js';
import { workspacePermissions } from '../../stores/workspacePermissions.svelte.js';
import CannedResponsePicker from '../support/CannedResponsePicker.svelte';
+ import CommentReactions from './CommentReactions.svelte';
const COMMENT_PAGE_SIZE = 25;
@@ -52,6 +53,12 @@
// comment.edit_others holders may moderate foreign comments (mirrors
// requireCommentEdit in the v2 API).
const canEditOthersComments = $derived(workspacePermissions.canEditOthersComments(workspaceId));
+ // Reacting needs the comment permission (mirrors requireCommentPermission in the v2 API).
+ const canReact = $derived(Boolean(authStore.currentUser) && (!workspaceId || workspacePermissions.canComment(workspaceId)));
+
+ function setReactions(commentId, reactions) {
+ comments = comments.map((c) => (c.id === commentId ? { ...c, reactions } : c));
+ }
// Get user's timezone
let timezone = $derived(getUserTimezone(authStore.currentUser));
@@ -649,6 +656,14 @@
+ {#if comment.source !== 'approval'}
+ setReactions(comment.id, reactions)}
+ onError={(message) => (error = message)}
+ />
+ {/if}
{/if}
diff --git a/frontend/src/lib/locales/de/workspace.js b/frontend/src/lib/locales/de/workspace.js
index 7ed84c51c..272e2405d 100644
--- a/frontend/src/lib/locales/de/workspace.js
+++ b/frontend/src/lib/locales/de/workspace.js
@@ -367,6 +367,15 @@ export default {
internal: 'Intern',
oldestFirst: 'Älteste zuerst',
newestFirst: 'Neueste zuerst',
+ addReaction: 'Reaktion hinzufügen',
+ reactionFailed: 'Reaktion konnte nicht gespeichert werden',
+ reactedBy: '{reaction} von {names}',
+ reactionThumbsUp: 'Daumen hoch',
+ reactionCheck: 'Erledigt',
+ reactionEyes: 'Gesehen',
+ reactionHeart: 'Danke',
+ reactionLaugh: 'Lachen',
+ reactionHooray: 'Super',
internalNoteHintGeneral: 'Nur für Teammitglieder sichtbar',
},
todo: {
diff --git a/frontend/src/lib/locales/en/workspace.js b/frontend/src/lib/locales/en/workspace.js
index 4c539b36e..02b3ea6cf 100644
--- a/frontend/src/lib/locales/en/workspace.js
+++ b/frontend/src/lib/locales/en/workspace.js
@@ -589,6 +589,15 @@ export default {
newestFirst: 'Newest first',
agentAuthored: 'Authored by an AI agent',
agentOwnedBy: 'AI agent owned by {owner}',
+ addReaction: 'Add reaction',
+ reactionFailed: 'Failed to update reaction',
+ reactedBy: '{reaction} by {names}',
+ reactionThumbsUp: 'Thumbs up',
+ reactionCheck: 'Done',
+ reactionEyes: 'Seen',
+ reactionHeart: 'Thanks',
+ reactionLaugh: 'Laugh',
+ reactionHooray: 'Hooray',
},
todo: {
diff --git a/frontend/src/lib/locales/fr/workspace.js b/frontend/src/lib/locales/fr/workspace.js
index ac6636a15..ae4b0c489 100644
--- a/frontend/src/lib/locales/fr/workspace.js
+++ b/frontend/src/lib/locales/fr/workspace.js
@@ -461,6 +461,15 @@ export default {
newestFirst: 'Plus récents en premier',
agentAuthored: 'Rédigé par un agent IA',
agentOwnedBy: 'Agent IA détenu par {owner}',
+ addReaction: 'Ajouter une réaction',
+ reactionFailed: 'Impossible de mettre à jour la réaction',
+ reactedBy: '{reaction} par {names}',
+ reactionThumbsUp: 'Pouce levé',
+ reactionCheck: 'Terminé',
+ reactionEyes: 'Vu',
+ reactionHeart: 'Merci',
+ reactionLaugh: 'Rire',
+ reactionHooray: 'Bravo',
},
todo: {
diff --git a/frontend/src/lib/locales/ru/workspace.js b/frontend/src/lib/locales/ru/workspace.js
index 894b0c66a..921a12855 100644
--- a/frontend/src/lib/locales/ru/workspace.js
+++ b/frontend/src/lib/locales/ru/workspace.js
@@ -64,7 +64,7 @@ export default {
},
comments: {
- failedToLoad: 'Не удалось загрузить комментарии', failedToCreate: 'Не удалось отправить комментарий', confirmDelete: 'Удалить этот комментарий?', failedToDelete: 'Не удалось удалить комментарий', failedToUpdate: 'Не удалось обновить комментарий', edited: 'изменено', newCommentsAvailable: 'Новых: {count}', editComment: 'Изменить комментарий', deleteComment: 'Удалить комментарий', editPlaceholder: 'Измените комментарий…', writePlaceholder: 'Добавить комментарий…', markdownSupported: 'Поддерживается Markdown', posting: 'Отправка…', comment: 'Комментарий', noComments: 'Комментариев пока нет', beFirstToComment: 'Оставьте первый комментарий к этому элементу.', internalNote: 'Внутренняя заметка', internalNoteHint: 'Не видна на портале', internalNoteHintGeneral: 'Видна только участникам команды', internal: 'Внутренний', oldestFirst: 'Сначала старые', newestFirst: 'Сначала новые', agentAuthored: 'Автор — ИИ-агент', agentOwnedBy: 'Владелец ИИ-агента: {owner}',
+ failedToLoad: 'Не удалось загрузить комментарии', failedToCreate: 'Не удалось отправить комментарий', confirmDelete: 'Удалить этот комментарий?', failedToDelete: 'Не удалось удалить комментарий', failedToUpdate: 'Не удалось обновить комментарий', edited: 'изменено', newCommentsAvailable: 'Новых: {count}', editComment: 'Изменить комментарий', deleteComment: 'Удалить комментарий', editPlaceholder: 'Измените комментарий…', writePlaceholder: 'Добавить комментарий…', markdownSupported: 'Поддерживается Markdown', posting: 'Отправка…', comment: 'Комментарий', noComments: 'Комментариев пока нет', beFirstToComment: 'Оставьте первый комментарий к этому элементу.', internalNote: 'Внутренняя заметка', internalNoteHint: 'Не видна на портале', internalNoteHintGeneral: 'Видна только участникам команды', internal: 'Внутренний', oldestFirst: 'Сначала старые', newestFirst: 'Сначала новые', agentAuthored: 'Автор — ИИ-агент', agentOwnedBy: 'Владелец ИИ-агента: {owner}', addReaction: 'Добавить реакцию', reactionFailed: 'Не удалось обновить реакцию', reactedBy: '{reaction} от: {names}', reactionThumbsUp: 'Нравится', reactionCheck: 'Готово', reactionEyes: 'Просмотрено', reactionHeart: 'Спасибо', reactionLaugh: 'Смех', reactionHooray: 'Ура',
},
todo: {
failedToCreate: 'Не удалось создать задачу', confirmDelete: 'Удалить эту задачу?', deleteTask: 'Удалить задачу', failedToDelete: 'Не удалось удалить задачу', loadingTasks: 'Загрузка задач…', myPersonalTasks: 'Мои личные задачи', whatNeedsToBeDone: 'Что нужно сделать?', addPersonalTask: 'Добавить личную задачу', noPersonalTasks: 'Личных задач нет', addFirstTask: 'Добавьте первую задачу, чтобы не забыть о ней.', ofPersonalTasksRemaining: 'Осталось личных задач: {count} из {total}',
diff --git a/frontend/src/lib/locales/zh-CN/workspace.js b/frontend/src/lib/locales/zh-CN/workspace.js
index fab25c02b..79b33941a 100644
--- a/frontend/src/lib/locales/zh-CN/workspace.js
+++ b/frontend/src/lib/locales/zh-CN/workspace.js
@@ -366,6 +366,15 @@ export default {
internal: '内部',
oldestFirst: '最早优先',
newestFirst: '最新优先',
+ addReaction: '添加回应',
+ reactionFailed: '无法更新回应',
+ reactedBy: '{names} 回应了 {reaction}',
+ reactionThumbsUp: '赞',
+ reactionCheck: '已完成',
+ reactionEyes: '已查看',
+ reactionHeart: '感谢',
+ reactionLaugh: '大笑',
+ reactionHooray: '欢呼',
},
todo: {
diff --git a/internal/database/migrations.go b/internal/database/migrations.go
index 5461a5a32..302e312b6 100644
--- a/internal/database/migrations.go
+++ b/internal/database/migrations.go
@@ -2870,6 +2870,38 @@ var Catalog = []Migration{
ALTER TABLE sla_goal_targets DROP COLUMN IF EXISTS priority_id;
`,
},
+ {
+ Version: "20261105_comment_reactions",
+ Name: "React to comments with a fixed set of emoji (core#298)",
+ CheckSQLite: sqliteTableCheck("comment_reactions"),
+ CheckPostgres: pgTableCheck("comment_reactions"),
+ SQLite: `
+ CREATE TABLE IF NOT EXISTS comment_reactions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ comment_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ reaction TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ UNIQUE(comment_id, user_id, reaction)
+ );
+ CREATE INDEX IF NOT EXISTS idx_comment_reactions_user_id ON comment_reactions(user_id);
+ `,
+ Postgres: `
+ CREATE TABLE IF NOT EXISTS comment_reactions (
+ id SERIAL PRIMARY KEY,
+ comment_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ reaction TEXT NOT NULL,
+ created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ UNIQUE(comment_id, user_id, reaction)
+ );
+ CREATE INDEX IF NOT EXISTS idx_comment_reactions_user_id ON comment_reactions(user_id);
+ `,
+ },
}
// applyAssetReportsWorkspacePinned backfills form-mode asset reports that have
diff --git a/internal/database/schema/content.sql b/internal/database/schema/content.sql
index fabf1b44a..ba433fbf1 100644
--- a/internal/database/schema/content.sql
+++ b/internal/database/schema/content.sql
@@ -57,6 +57,20 @@ CREATE INDEX IF NOT EXISTS idx_comments_item_id ON comments(item_id);
CREATE INDEX IF NOT EXISTS idx_comments_author_id ON comments(author_id);
CREATE INDEX IF NOT EXISTS idx_comments_portal_customer_id ON comments(portal_customer_id);
+-- Comment reactions: one row per user, comment, and reaction key
+CREATE TABLE IF NOT EXISTS comment_reactions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ comment_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ reaction TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ UNIQUE(comment_id, user_id, reaction)
+);
+
+CREATE INDEX IF NOT EXISTS idx_comment_reactions_user_id ON comment_reactions(user_id);
+
-- Diagram System Tables
CREATE TABLE IF NOT EXISTS item_diagrams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
diff --git a/internal/database/schema/content_postgres.sql b/internal/database/schema/content_postgres.sql
index c87f23bcc..ffc3a8db3 100644
--- a/internal/database/schema/content_postgres.sql
+++ b/internal/database/schema/content_postgres.sql
@@ -57,6 +57,20 @@ CREATE INDEX IF NOT EXISTS idx_comments_item_id ON comments(item_id);
CREATE INDEX IF NOT EXISTS idx_comments_author_id ON comments(author_id);
CREATE INDEX IF NOT EXISTS idx_comments_portal_customer_id ON comments(portal_customer_id);
+-- Comment reactions: one row per user, comment, and reaction key
+CREATE TABLE IF NOT EXISTS comment_reactions (
+ id SERIAL PRIMARY KEY,
+ comment_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ reaction TEXT NOT NULL,
+ created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ UNIQUE(comment_id, user_id, reaction)
+);
+
+CREATE INDEX IF NOT EXISTS idx_comment_reactions_user_id ON comment_reactions(user_id);
+
-- Diagram System Tables
CREATE TABLE IF NOT EXISTS item_diagrams (
id SERIAL PRIMARY KEY,
diff --git a/internal/models/item.go b/internal/models/item.go
index 809ded2c9..5956fd1b0 100644
--- a/internal/models/item.go
+++ b/internal/models/item.go
@@ -245,6 +245,18 @@ type Comment struct {
IsAgent bool `json:"is_agent,omitempty"`
// AgentOwnerName is permission-filtered by the comment-list handler.
AgentOwnerName string `json:"agent_owner_name,omitempty"`
+ // Reactions groups the emoji reactions on a human comment in the fixed
+ // order of services.CommentReactionKeys. Approval rows never carry any.
+ Reactions []CommentReaction `json:"reactions,omitempty" db:"-"`
+}
+
+// CommentReaction is one reaction key on a comment with the users who chose
+// it, oldest first.
+type CommentReaction struct {
+ Reaction string `json:"reaction"`
+ Count int `json:"count"`
+ UserIDs []int `json:"user_ids"`
+ UserNames []string `json:"user_names"`
}
// Mention represents an @mention in a comment or item description
diff --git a/internal/restapi/v2/comments.go b/internal/restapi/v2/comments.go
index 8185b0b1c..fb6deb7f0 100644
--- a/internal/restapi/v2/comments.go
+++ b/internal/restapi/v2/comments.go
@@ -20,6 +20,8 @@ func registerCommentRoutes(builder *routeBuilder, deps Deps) {
builder.JSON(http.MethodPatch, "/comments/{comment_id}", http.StatusOK, true, AuthAuthenticated, []string{"items:write"}, updateComment(deps))
builder.Command(http.MethodDelete, "/comments/{comment_id}", AuthAuthenticated, []string{"items:delete"}, deleteComment(deps))
builder.JSON(http.MethodPost, "/comments/batch", http.StatusOK, false, AuthAuthenticated, []string{"items:read"}, commentsBatch(deps))
+ builder.JSON(http.MethodPost, "/comments/{comment_id}/reactions", http.StatusOK, false, AuthAuthenticated, []string{"items:write"}, addCommentReaction(deps))
+ builder.Command(http.MethodDelete, "/comments/{comment_id}/reactions/{reaction}", AuthAuthenticated, []string{"items:write"}, removeCommentReaction(deps))
}
type commentFeedDTO struct {
@@ -113,6 +115,9 @@ func commentsBatch(deps Deps) jsonOperation[commentBatchRequest, []commentBatchE
if err != nil {
return nil, internalError(err)
}
+ if err := attachBatchReactions(deps, pages); err != nil {
+ return nil, internalError(err)
+ }
entries := make([]commentBatchEntry, 0, len(visible))
for _, itemID := range visible {
page := pages[itemID]
@@ -162,6 +167,10 @@ func normalizeCommentBatchItemIDs(input []int) ([]int, error) {
return ids, nil
}
+type commentReactionRequest struct {
+ Reaction string `json:"reaction"`
+}
+
type commentPatchRequest struct {
Content Optional[string] `json:"content"`
}
@@ -190,6 +199,9 @@ func listComments(deps Deps) readOperation[commentFeedDTO] {
if err := renderCommentHTML(page.Comments); err != nil {
return commentFeedDTO{}, internalError(err)
}
+ if err := deps.Comments.AttachReactions(page.Comments); err != nil {
+ return commentFeedDTO{}, internalError(err)
+ }
var total *int
if options.Before == nil && options.Since == nil {
count, err := deps.Comments.CountFeedByItemID(item.ID)
@@ -244,9 +256,12 @@ func getComment(deps Deps) readOperation[models.Comment] {
if err := requireCommentRead(r, deps, user.ID, comment.ItemID, comment.WorkspaceID); err != nil {
return models.Comment{}, err
}
- body := comment.Comment
- body.ContentHTML, err = markdown.Render(body.Content)
- return body, commentError(err)
+ body := []models.Comment{comment.Comment}
+ if err := deps.Comments.AttachReactions(body); err != nil {
+ return models.Comment{}, internalError(err)
+ }
+ body[0].ContentHTML, err = markdown.Render(body[0].Content)
+ return body[0], commentError(err)
}
}
@@ -270,11 +285,88 @@ func updateComment(deps Deps) jsonOperation[commentPatchRequest, models.Comment]
if err != nil {
return models.Comment{}, commentError(err)
}
- updated.ContentHTML, err = markdown.Render(updated.Content)
- return *updated, commentError(err)
+ body := []models.Comment{*updated}
+ if err := deps.Comments.AttachReactions(body); err != nil {
+ return models.Comment{}, internalError(err)
+ }
+ body[0].ContentHTML, err = markdown.Render(body[0].Content)
+ return body[0], commentError(err)
+ }
+}
+
+// addCommentReaction records the caller's reaction (core#298) and returns the
+// comment's grouped reactions. Reacting needs the same permission as
+// commenting; adding an existing reaction is a no-op.
+func addCommentReaction(deps Deps) jsonOperation[commentReactionRequest, []models.CommentReaction] {
+ return func(r *http.Request, input commentReactionRequest) ([]models.CommentReaction, error) {
+ user, comment, err := commentReactionTarget(r, deps, input.Reaction)
+ if err != nil {
+ return nil, err
+ }
+ reactions, err := deps.Comments.AddReaction(comment.ID, user.ID, input.Reaction)
+ return reactions, commentError(err)
+ }
+}
+
+// removeCommentReaction withdraws the caller's own reaction; removing a
+// reaction that does not exist is a no-op.
+func removeCommentReaction(deps Deps) commandOperation {
+ return func(r *http.Request) error {
+ reaction := r.PathValue("reaction")
+ user, comment, err := commentReactionTarget(r, deps, reaction)
+ if err != nil {
+ return err
+ }
+ return commentError(deps.Comments.RemoveReaction(comment.ID, user.ID, reaction))
}
}
+func commentReactionTarget(r *http.Request, deps Deps, reaction string) (*models.User, *services.CommentWithDetails, error) {
+ if !services.ValidCommentReaction(reaction) {
+ err := newError(http.StatusBadRequest, "invalid_request", "reaction must be one of "+strings.Join(services.CommentReactionKeys, ", "))
+ err.Details = map[string]string{"field": "reaction"}
+ return nil, nil, err
+ }
+ user, comment, err := commentTarget(r, deps)
+ if err != nil {
+ return nil, nil, err
+ }
+ if err := requireCommentRead(r, deps, user.ID, comment.ItemID, comment.WorkspaceID); err != nil {
+ return nil, nil, err
+ }
+ if err := requireCommentPermission(deps, user.ID, comment.WorkspaceID, models.PermissionItemComment); err != nil {
+ return nil, nil, err
+ }
+ return user, comment, nil
+}
+
+func attachBatchReactions(deps Deps, pages map[int]*services.CommentFeedPage) error {
+ var all []models.Comment
+ for _, page := range pages {
+ if page != nil {
+ all = append(all, page.Comments...)
+ }
+ }
+ if err := deps.Comments.AttachReactions(all); err != nil {
+ return err
+ }
+ byID := make(map[int][]models.CommentReaction, len(all))
+ for _, comment := range all {
+ if comment.Reactions != nil {
+ byID[comment.ID] = comment.Reactions
+ }
+ }
+ for _, page := range pages {
+ if page == nil {
+ continue
+ }
+ for i := range page.Comments {
+ page.Comments[i].Reactions = byID[page.Comments[i].ID]
+ }
+ }
+ return nil
+}
+
func deleteComment(deps Deps) commandOperation {
return func(r *http.Request) error {
user, comment, err := commentTarget(r, deps)
@@ -439,6 +531,8 @@ func commentError(err error) error {
return newError(http.StatusNotFound, "not_found", "Comment was not found")
case errors.As(err, &invalid):
return newError(http.StatusBadRequest, "invalid_request", invalid.Message)
+ case errors.Is(err, services.ErrInvalidCommentReaction):
+ return newError(http.StatusBadRequest, "invalid_request", err.Error())
case errors.As(err, &serviceErr):
return newError(serviceErr.StatusCode, "conflict", serviceErr.Message)
default:
diff --git a/internal/restapi/v2/contract-metadata.json b/internal/restapi/v2/contract-metadata.json
index 2e79a80ff..142adf424 100644
--- a/internal/restapi/v2/contract-metadata.json
+++ b/internal/restapi/v2/contract-metadata.json
@@ -3832,6 +3832,83 @@
]
}
},
+ "/comments/{comment_id}/reactions": {
+ "post": {
+ "description": "Add comment reaction. The server validates resource ownership and command preconditions before persisting changes. Unknown JSON fields are rejected when the operation accepts a body.",
+ "parameters": [
+ {
+ "Name": "comment_id",
+ "In": "path",
+ "Description": "The comment identifier.",
+ "Required": true,
+ "Schema": {
+ "minimum": 1,
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "400": {},
+ "401": {},
+ "403": {},
+ "404": {},
+ "413": {},
+ "415": {},
+ "429": {},
+ "500": {}
+ },
+ "summary": "Add comment reaction",
+ "tags": [
+ "Work items"
+ ]
+ }
+ },
+ "/comments/{comment_id}/reactions/{reaction}": {
+ "delete": {
+ "description": "Remove comment reaction. The server verifies resource ownership and deletion preconditions before removing the resource.",
+ "parameters": [
+ {
+ "Name": "comment_id",
+ "In": "path",
+ "Description": "The comment identifier.",
+ "Required": true,
+ "Schema": {
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ {
+ "Name": "reaction",
+ "In": "path",
+ "Description": "The reaction key: thumbs_up, check, eyes, heart, laugh, or hooray.",
+ "Required": true,
+ "Schema": {
+ "enum": [
+ "thumbs_up",
+ "check",
+ "eyes",
+ "heart",
+ "laugh",
+ "hooray"
+ ],
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "400": {},
+ "401": {},
+ "403": {},
+ "404": {},
+ "429": {},
+ "500": {}
+ },
+ "summary": "Remove comment reaction",
+ "tags": [
+ "Work items"
+ ]
+ }
+ },
"/condition-sets": {
"get": {
"description": "List condition sets. Results include only resources visible to the authenticated caller; collections use their documented stable order and bounds.",
diff --git a/internal/restapi/v2/router.go b/internal/restapi/v2/router.go
index 9b20eb9d7..d3df68464 100644
--- a/internal/restapi/v2/router.go
+++ b/internal/restapi/v2/router.go
@@ -451,6 +451,9 @@ type commentApplication interface {
Create(services.CreateCommentParams) (*services.CreateCommentResult, error)
UpdateWithEffects(services.UpdateCommentParams) (*models.Comment, error)
DeleteWithEffects(services.DeleteCommentParams) error
+ AttachReactions([]models.Comment) error
+ AddReaction(commentID, userID int, reaction string) ([]models.CommentReaction, error)
+ RemoveReaction(commentID, userID int, reaction string) error
}
type commentAccess interface {
diff --git a/internal/services/comment_reactions.go b/internal/services/comment_reactions.go
new file mode 100644
index 000000000..27dcf4d8e
--- /dev/null
+++ b/internal/services/comment_reactions.go
@@ -0,0 +1,162 @@
+package services
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+ "slices"
+
+ "windshift/internal/models"
+ "windshift/internal/repository"
+)
+
+// CommentReactionKeys is the fixed set of reactions a user can leave on a
+// comment, in display order (core#298). Keys, not emoji, travel over the API
+// so clients render them consistently.
+var CommentReactionKeys = []string{"thumbs_up", "check", "eyes", "heart", "laugh", "hooray"}
+
+// ErrInvalidCommentReaction reports a reaction key outside CommentReactionKeys.
+var ErrInvalidCommentReaction = errors.New("reaction is not supported")
+
+// maxReactionCommentIDs bounds one IN clause; comment feeds return at most
+// MaxCommentFeedLimit rows per item, but batches span many items.
+const maxReactionCommentIDs = 500
+
+// ValidCommentReaction reports whether key is a supported reaction.
+func ValidCommentReaction(key string) bool {
+ return slices.Contains(CommentReactionKeys, key)
+}
+
+// AddReaction records userID's reaction on a comment. Adding the same
+// reaction twice is a no-op.
+func (s *CommentService) AddReaction(commentID, userID int, reaction string) ([]models.CommentReaction, error) {
+ if !ValidCommentReaction(reaction) {
+ return nil, ErrInvalidCommentReaction
+ }
+ itemID, err := s.reactionCommentItem(commentID)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := s.db.ExecWrite(`
+ INSERT INTO comment_reactions (comment_id, user_id, reaction) VALUES (?, ?, ?)
+ ON CONFLICT (comment_id, user_id, reaction) DO NOTHING`, commentID, userID, reaction); err != nil {
+ return nil, fmt.Errorf("add reaction to comment %d: %w", commentID, err)
+ }
+ PublishItemChange(itemID)
+ return s.ReactionsForComment(commentID)
+}
+
+// RemoveReaction withdraws userID's reaction from a comment. Removing a
+// reaction that does not exist is a no-op.
+func (s *CommentService) RemoveReaction(commentID, userID int, reaction string) error {
+ if !ValidCommentReaction(reaction) {
+ return ErrInvalidCommentReaction
+ }
+ itemID, err := s.reactionCommentItem(commentID)
+ if err != nil {
+ return err
+ }
+ if _, err := s.db.ExecWrite(`DELETE FROM comment_reactions WHERE comment_id = ? AND user_id = ? AND reaction = ?`,
+ commentID, userID, reaction); err != nil {
+ return fmt.Errorf("remove reaction from comment %d: %w", commentID, err)
+ }
+ PublishItemChange(itemID)
+ return nil
+}
+
+// ReactionsForComment returns the grouped reactions on one comment.
+func (s *CommentService) ReactionsForComment(commentID int) ([]models.CommentReaction, error) {
+ byComment, err := s.reactionsByComment([]int{commentID})
+ if err != nil {
+ return nil, err
+ }
+ reactions := byComment[commentID]
+ if reactions == nil {
+ reactions = []models.CommentReaction{}
+ }
+ return reactions, nil
+}
+
+// AttachReactions fills Reactions on the human comments in place. Approval
+// rows (non-positive IDs) are skipped.
+func (s *CommentService) AttachReactions(comments []models.Comment) error {
+ ids := make([]int, 0, len(comments))
+ for _, comment := range comments {
+ if comment.ID > 0 && comment.Source != "approval" {
+ ids = append(ids, comment.ID)
+ }
+ }
+ byComment, err := s.reactionsByComment(ids)
+ if err != nil {
+ return err
+ }
+ for i := range comments {
+ comments[i].Reactions = byComment[comments[i].ID]
+ }
+ return nil
+}
+
+func (s *CommentService) reactionCommentItem(commentID int) (int, error) {
+ var itemID int
+ err := s.db.QueryRow(`SELECT item_id FROM comments WHERE id = ?`, commentID).Scan(&itemID)
+ if errors.Is(err, sql.ErrNoRows) {
+ return 0, fmt.Errorf("comment %d: %w", commentID, repository.ErrNotFound)
+ }
+ if err != nil {
+ return 0, fmt.Errorf("find comment %d: %w", commentID, err)
+ }
+ return itemID, nil
+}
+
+func (s *CommentService) reactionsByComment(ids []int) (map[int][]models.CommentReaction, error) {
+ result := make(map[int][]models.CommentReaction)
+ for start := 0; start < len(ids); start += maxReactionCommentIDs {
+ chunk := ids[start:min(start+maxReactionCommentIDs, len(ids))]
+ args := make([]any, len(chunk))
+ for i, id := range chunk {
+ args[i] = id
+ }
+ rows, err := s.db.Query(fmt.Sprintf(`
+ SELECT r.comment_id, r.reaction, r.user_id,
+ COALESCE(NULLIF(TRIM(COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')), ''), u.username, 'Unknown User')
+ FROM comment_reactions r
+ JOIN users u ON u.id = r.user_id
+ WHERE r.comment_id IN (%s)
+ ORDER BY r.comment_id, r.created_at, r.id`, placeholders(len(chunk))), args...)
+ if err != nil {
+ return nil, fmt.Errorf("list comment reactions: %w", err)
+ }
+ for rows.Next() {
+ var commentID, userID int
+ var reaction, name string
+ if err := rows.Scan(&commentID, &reaction, &userID, &name); err != nil {
+ _ = rows.Close()
+ return nil, fmt.Errorf("scan comment reaction: %w", err)
+ }
+ result[commentID] = appendReaction(result[commentID], reaction, userID, name)
+ }
+ err = rows.Err()
+ _ = rows.Close()
+ if err != nil {
+ return nil, fmt.Errorf("read comment reactions: %w", err)
+ }
+ }
+ for id := range result {
+ slices.SortStableFunc(result[id], func(a, b models.CommentReaction) int {
+ return slices.Index(CommentReactionKeys, a.Reaction) - slices.Index(CommentReactionKeys, b.Reaction)
+ })
+ }
+ return result, nil
+}
+
+func appendReaction(groups []models.CommentReaction, reaction string, userID int, name string) []models.CommentReaction {
+ for i := range groups {
+ if groups[i].Reaction == reaction {
+ groups[i].Count++
+ groups[i].UserIDs = append(groups[i].UserIDs, userID)
+ groups[i].UserNames = append(groups[i].UserNames, name)
+ return groups
+ }
+ }
+ return append(groups, models.CommentReaction{Reaction: reaction, Count: 1, UserIDs: []int{userID}, UserNames: []string{name}})
+}
From 5b998ad3f33b558d635784381fc24ba2e3855edf Mon Sep 17 00:00:00 2001
From: Stefan Ernst <24554551+stefan-ernst@users.noreply.github.com>
Date: Wed, 21 Oct 2026 10:48:34 +0200
Subject: [PATCH 2/3] fix(deps): bump Go to 1.27.3 for the crypto/x509 fix
(GO-2026-6809)
---
Dockerfile | 2 +-
Dockerfile.logbook | 2 +-
deploy/coding-agent/Dockerfile | 2 +-
deploy/windshift-runner/Dockerfile | 2 +-
go.mod | 2 +-
release.sh | 2 +-
scripts/demo/Dockerfile | 2 +-
7 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 758f9d8f6..e58aed551 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -23,7 +23,7 @@ RUN VITE_APP_VERSION_CODE="${VERSION}" \
npm run build
# Stage 2: Build Go binary
-FROM golang:1.27.2-alpine@sha256:85dc1069ac644ea3c527b177303a406eb3358192816cd7f9e5848eb658851673 AS builder
+FROM golang:1.27.3-alpine@sha256:f2c872bcee949b7be9c490efd02b48a8a2fbf947cdf533f1c58525ec9d8170fd AS builder
# Install build dependencies (no gcc/musl-dev needed - pure Go SQLite driver)
RUN apk add --no-cache git tzdata
diff --git a/Dockerfile.logbook b/Dockerfile.logbook
index fd6df0a13..80187b31f 100644
--- a/Dockerfile.logbook
+++ b/Dockerfile.logbook
@@ -1,7 +1,7 @@
# Multi-stage build for Windshift Logbook service
# Stage 1: Build Go binary
-FROM golang:1.27.2-alpine@sha256:85dc1069ac644ea3c527b177303a406eb3358192816cd7f9e5848eb658851673 AS builder
+FROM golang:1.27.3-alpine@sha256:f2c872bcee949b7be9c490efd02b48a8a2fbf947cdf533f1c58525ec9d8170fd AS builder
# Install build dependencies
RUN apk add --no-cache git
diff --git a/deploy/coding-agent/Dockerfile b/deploy/coding-agent/Dockerfile
index f51051eef..2149410d7 100644
--- a/deploy/coding-agent/Dockerfile
+++ b/deploy/coding-agent/Dockerfile
@@ -21,7 +21,7 @@
# --------------------------------------------------------------------
# Stage 1: cross-compile the ws CLI for linux/
# --------------------------------------------------------------------
-FROM golang:1.27.2-bookworm@sha256:5cf287a799e6b94384bad13d16b14904c531f51ba65792237e122ce42b392f61 AS ws-build
+FROM golang:1.27.3-bookworm@sha256:32fa283222e2905d9fee971f60c842e5c815fdc036cf7ea599bf36bd7ca8f67f AS ws-build
WORKDIR /src
diff --git a/deploy/windshift-runner/Dockerfile b/deploy/windshift-runner/Dockerfile
index 624550aeb..89cc2381d 100644
--- a/deploy/windshift-runner/Dockerfile
+++ b/deploy/windshift-runner/Dockerfile
@@ -26,7 +26,7 @@
# --------------------------------------------------------------------
# Stage 1: build windshift-runner + windshift-triage
# --------------------------------------------------------------------
-FROM golang:1.27.2-alpine@sha256:85dc1069ac644ea3c527b177303a406eb3358192816cd7f9e5848eb658851673 AS build
+FROM golang:1.27.3-alpine@sha256:f2c872bcee949b7be9c490efd02b48a8a2fbf947cdf533f1c58525ec9d8170fd AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
diff --git a/go.mod b/go.mod
index 860349672..e568fec51 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module windshift
-go 1.27.2
+go 1.27.3
require (
charm.land/bubbles/v2 v2.2.0
diff --git a/release.sh b/release.sh
index c21861436..46bb0b172 100755
--- a/release.sh
+++ b/release.sh
@@ -888,7 +888,7 @@ write_release_provenance() {
echo ""
echo "Resolved base image digests at build time"
echo "-----------------------------------------"
- for image in node:24.18.0-alpine golang:1.27.2-alpine golang:1.27.2-bookworm node:lts-slim; do
+ for image in node:24.18.0-alpine golang:1.27.3-alpine golang:1.27.3-bookworm node:lts-slim; do
docker buildx imagetools inspect "$image" 2>/dev/null | grep -E 'Name:|Digest:' || true
done
fi
diff --git a/scripts/demo/Dockerfile b/scripts/demo/Dockerfile
index 2330116c3..8a9527c3c 100644
--- a/scripts/demo/Dockerfile
+++ b/scripts/demo/Dockerfile
@@ -26,7 +26,7 @@ RUN npm run build
# ============================================================
# Stage 1b: Build windshift server binary
# ============================================================
-FROM golang:1.27.2-alpine@sha256:85dc1069ac644ea3c527b177303a406eb3358192816cd7f9e5848eb658851673 AS builder
+FROM golang:1.27.3-alpine@sha256:f2c872bcee949b7be9c490efd02b48a8a2fbf947cdf533f1c58525ec9d8170fd AS builder
# Install build dependencies
RUN apk add --no-cache git
From cbcd2842c98018eea43d105c70acb2f07f9f5e06 Mon Sep 17 00:00:00 2001
From: Stefan Ernst <24554551+stefan-ernst@users.noreply.github.com>
Date: Wed, 21 Oct 2026 10:52:52 +0200
Subject: [PATCH 3/3] test(comments): add a stable test id to reaction picker
options
---
frontend/src/lib/features/items/CommentReactions.svelte | 1 +
1 file changed, 1 insertion(+)
diff --git a/frontend/src/lib/features/items/CommentReactions.svelte b/frontend/src/lib/features/items/CommentReactions.svelte
index 5e1e7a1b4..c1b319446 100644
--- a/frontend/src/lib/features/items/CommentReactions.svelte
+++ b/frontend/src/lib/features/items/CommentReactions.svelte
@@ -143,6 +143,7 @@
type="button"
role="menuitem"
onclick={() => toggle(reaction.key)}
+ data-testid="comment-reaction-option"
data-reaction={reaction.key}
title={t(reaction.label)}
aria-label={t(reaction.label)}