Skip to content

Commit 313a332

Browse files
authored
feat: add message feedback (thumbs up/down) (#1594)
* feat: add message feedback (thumbs up/down) with modal for dislike details Add per-message feedback system allowing users to rate routing quality. Thumbs up/down column appears first in both Messages and Overview tables. Dislike opens a compact modal with tags (Not expected tier, Poor answer quality, Too slow, Buggy, Other) and optional text details. Feedback is stored in agent_messages table via new migration. * style: use filled thumb icons, teal active color, tighter feedback cell padding - Thumb up active color: #1BC4BF (teal) - Thumb down active: foreground color - Inactive sibling fades to 20% opacity when other is active - Reduced feedback cell padding-right to 0 * chore: add changeset for message feedback feature * test: add FeedbackModal and FeedbackCell test coverage * test: add feedback coverage for MessageLog, Overview, FeedbackModal, and FeedbackCell * feat: hide feedback column in local mode Feedback data in local mode stays in SQLite and doesn't flow to the cloud backend, making thumbs up/down misleading. Detect local mode via checkIsLocalMode() and filter out the feedback column, handlers, and modal from both MessageLog and Overview pages. * test: add coverage for feedback error rollback and API functions Cover setMessageFeedback, clearMessageFeedback, getMessageDetails in api.test.ts. Add error rollback tests for optimistic like/dislike/clear in both MessageLog and Overview pages.
1 parent 028cbca commit 313a332

30 files changed

+1802
-24
lines changed

.changeset/message-feedback.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"manifest": minor
3+
---
4+
5+
Add per-message feedback (thumbs up/down) to Messages and Overview pages

packages/backend/src/analytics/analytics.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { AgentLifecycleService } from './services/agent-lifecycle.service';
1313
import { TimeseriesQueriesService } from './services/timeseries-queries.service';
1414
import { MessagesQueryService } from './services/messages-query.service';
1515
import { MessageDetailsService } from './services/message-details.service';
16+
import { MessageFeedbackService } from './services/message-feedback.service';
1617
import { AgentAnalyticsService } from './services/agent-analytics.service';
1718
import { OverviewController } from './controllers/overview.controller';
1819
import { TokensController } from './controllers/tokens.controller';
@@ -41,6 +42,7 @@ import { AgentAnalyticsController } from './controllers/agent-analytics.controll
4142
TimeseriesQueriesService,
4243
MessagesQueryService,
4344
MessageDetailsService,
45+
MessageFeedbackService,
4446
AgentAnalyticsService,
4547
],
4648
})

packages/backend/src/analytics/controllers/messages.controller.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ import { Test, TestingModule } from '@nestjs/testing';
22
import { MessagesController } from './messages.controller';
33
import { MessagesQueryService } from '../services/messages-query.service';
44
import { MessageDetailsService } from '../services/message-details.service';
5+
import { MessageFeedbackService } from '../services/message-feedback.service';
56

67
describe('MessagesController', () => {
78
let controller: MessagesController;
89
let mockGetMessages: jest.Mock;
910
let mockGetDetails: jest.Mock;
11+
let mockSetFeedback: jest.Mock;
12+
let mockClearFeedback: jest.Mock;
1013

1114
beforeEach(async () => {
1215
mockGetMessages = jest.fn().mockResolvedValue({
@@ -23,6 +26,9 @@ describe('MessagesController', () => {
2326
agent_logs: [],
2427
});
2528

29+
mockSetFeedback = jest.fn().mockResolvedValue(undefined);
30+
mockClearFeedback = jest.fn().mockResolvedValue(undefined);
31+
2632
const module: TestingModule = await Test.createTestingModule({
2733
controllers: [MessagesController],
2834
providers: [
@@ -34,6 +40,10 @@ describe('MessagesController', () => {
3440
provide: MessageDetailsService,
3541
useValue: { getDetails: mockGetDetails },
3642
},
43+
{
44+
provide: MessageFeedbackService,
45+
useValue: { setFeedback: mockSetFeedback, clearFeedback: mockClearFeedback },
46+
},
3747
],
3848
}).compile();
3949

@@ -128,4 +138,33 @@ describe('MessagesController', () => {
128138

129139
expect(result).toEqual(expected);
130140
});
141+
142+
it('delegates setFeedback to feedback service', async () => {
143+
const user = { id: 'u1' };
144+
const body = { rating: 'dislike' as const, tags: ['Slow or buggy'], details: 'test' };
145+
await controller.setFeedback('msg-1', body, user as never);
146+
147+
expect(mockSetFeedback).toHaveBeenCalledWith(
148+
'msg-1',
149+
'u1',
150+
'dislike',
151+
['Slow or buggy'],
152+
'test',
153+
);
154+
});
155+
156+
it('delegates setFeedback with rating only', async () => {
157+
const user = { id: 'u1' };
158+
const body = { rating: 'like' as const };
159+
await controller.setFeedback('msg-1', body, user as never);
160+
161+
expect(mockSetFeedback).toHaveBeenCalledWith('msg-1', 'u1', 'like', undefined, undefined);
162+
});
163+
164+
it('delegates clearFeedback to feedback service', async () => {
165+
const user = { id: 'u1' };
166+
await controller.clearFeedback('msg-1', user as never);
167+
168+
expect(mockClearFeedback).toHaveBeenCalledWith('msg-1', 'u1');
169+
});
131170
});

packages/backend/src/analytics/controllers/messages.controller.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
1-
import { Controller, Get, Param, Query } from '@nestjs/common';
1+
import {
2+
Controller,
3+
Get,
4+
Patch,
5+
Delete,
6+
Param,
7+
Query,
8+
Body,
9+
HttpCode,
10+
HttpStatus,
11+
} from '@nestjs/common';
212
import { MessagesQueryDto } from '../dto/messages-query.dto';
13+
import { MessageFeedbackDto } from '../dto/message-feedback.dto';
314
import { MessagesQueryService } from '../services/messages-query.service';
415
import { MessageDetailsService } from '../services/message-details.service';
16+
import { MessageFeedbackService } from '../services/message-feedback.service';
517
import { CurrentUser } from '../../auth/current-user.decorator';
618
import { AuthUser } from '../../auth/auth.instance';
719

@@ -10,6 +22,7 @@ export class MessagesController {
1022
constructor(
1123
private readonly messagesQuery: MessagesQueryService,
1224
private readonly messageDetails: MessageDetailsService,
25+
private readonly messageFeedback: MessageFeedbackService,
1326
) {}
1427

1528
@Get('messages')
@@ -31,4 +44,20 @@ export class MessagesController {
3144
async getMessageDetails(@Param('id') id: string, @CurrentUser() user: AuthUser) {
3245
return this.messageDetails.getDetails(id, user.id);
3346
}
47+
48+
@Patch('messages/:id/feedback')
49+
@HttpCode(HttpStatus.NO_CONTENT)
50+
async setFeedback(
51+
@Param('id') id: string,
52+
@Body() body: MessageFeedbackDto,
53+
@CurrentUser() user: AuthUser,
54+
) {
55+
await this.messageFeedback.setFeedback(id, user.id, body.rating, body.tags, body.details);
56+
}
57+
58+
@Delete('messages/:id/feedback')
59+
@HttpCode(HttpStatus.NO_CONTENT)
60+
async clearFeedback(@Param('id') id: string, @CurrentUser() user: AuthUser) {
61+
await this.messageFeedback.clearFeedback(id, user.id);
62+
}
3463
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import 'reflect-metadata';
2+
import { validate } from 'class-validator';
3+
import { plainToInstance } from 'class-transformer';
4+
import { MessageFeedbackDto } from './message-feedback.dto';
5+
6+
describe('MessageFeedbackDto', () => {
7+
it('accepts valid like rating', async () => {
8+
const dto = plainToInstance(MessageFeedbackDto, { rating: 'like' });
9+
const errors = await validate(dto);
10+
expect(errors).toHaveLength(0);
11+
});
12+
13+
it('accepts valid dislike rating with tags and details', async () => {
14+
const dto = plainToInstance(MessageFeedbackDto, {
15+
rating: 'dislike',
16+
tags: ['Too slow', 'Buggy'],
17+
details: 'Response was too slow',
18+
});
19+
const errors = await validate(dto);
20+
expect(errors).toHaveLength(0);
21+
});
22+
23+
it('rejects missing rating', async () => {
24+
const dto = plainToInstance(MessageFeedbackDto, {});
25+
const errors = await validate(dto);
26+
expect(errors.length).toBeGreaterThan(0);
27+
});
28+
29+
it('rejects invalid rating value', async () => {
30+
const dto = plainToInstance(MessageFeedbackDto, { rating: 'neutral' });
31+
const errors = await validate(dto);
32+
expect(errors.length).toBeGreaterThan(0);
33+
});
34+
35+
it('allows omitting tags and details', async () => {
36+
const dto = plainToInstance(MessageFeedbackDto, { rating: 'dislike' });
37+
const errors = await validate(dto);
38+
expect(errors).toHaveLength(0);
39+
});
40+
41+
it('rejects non-array tags', async () => {
42+
const dto = plainToInstance(MessageFeedbackDto, { rating: 'dislike', tags: 'not-an-array' });
43+
const errors = await validate(dto);
44+
expect(errors.length).toBeGreaterThan(0);
45+
});
46+
47+
it('rejects non-string tag values', async () => {
48+
const dto = plainToInstance(MessageFeedbackDto, { rating: 'dislike', tags: [123] });
49+
const errors = await validate(dto);
50+
expect(errors.length).toBeGreaterThan(0);
51+
});
52+
53+
it('rejects details exceeding 2000 characters', async () => {
54+
const dto = plainToInstance(MessageFeedbackDto, {
55+
rating: 'dislike',
56+
details: 'x'.repeat(2001),
57+
});
58+
const errors = await validate(dto);
59+
expect(errors.length).toBeGreaterThan(0);
60+
});
61+
62+
it('accepts details at exactly 2000 characters', async () => {
63+
const dto = plainToInstance(MessageFeedbackDto, {
64+
rating: 'dislike',
65+
details: 'x'.repeat(2000),
66+
});
67+
const errors = await validate(dto);
68+
expect(errors).toHaveLength(0);
69+
});
70+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { IsIn, IsOptional, IsString, IsArray, MaxLength } from 'class-validator';
2+
3+
export const FEEDBACK_RATINGS = ['like', 'dislike'] as const;
4+
5+
export const FEEDBACK_TAGS = [
6+
'Not expected tier',
7+
'Poor answer quality',
8+
'Too slow',
9+
'Buggy',
10+
'Other',
11+
] as const;
12+
13+
export class MessageFeedbackDto {
14+
@IsIn(FEEDBACK_RATINGS)
15+
rating!: 'like' | 'dislike';
16+
17+
@IsOptional()
18+
@IsArray()
19+
@IsString({ each: true })
20+
tags?: string[];
21+
22+
@IsOptional()
23+
@IsString()
24+
@MaxLength(2000)
25+
details?: string;
26+
}

packages/backend/src/analytics/services/message-details.service.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ describe('MessageDetailsService', () => {
5555
fallback_index: null,
5656
session_key: 'sess-001',
5757
user_id: 'u1',
58+
feedback_rating: null,
59+
feedback_tags: null,
60+
feedback_details: null,
5861
};
5962

6063
beforeEach(async () => {
@@ -232,9 +235,28 @@ describe('MessageDetailsService', () => {
232235
fallback_from_model: null,
233236
fallback_index: null,
234237
session_key: 'sess-001',
238+
feedback_rating: null,
239+
feedback_tags: null,
240+
feedback_details: null,
235241
});
236242
});
237243

244+
it('splits feedback_tags into an array when present', async () => {
245+
const msgWithFeedback = {
246+
...baseMessage,
247+
feedback_rating: 'dislike',
248+
feedback_tags: 'Too slow,Buggy',
249+
feedback_details: 'Response was slow',
250+
};
251+
msgQb.getOne.mockResolvedValue(msgWithFeedback);
252+
253+
const result = await service.getDetails('msg-1', 'u1');
254+
255+
expect(result.message.feedback_rating).toBe('dislike');
256+
expect(result.message.feedback_tags).toEqual(['Too slow', 'Buggy']);
257+
expect(result.message.feedback_details).toBe('Response was slow');
258+
});
259+
238260
it('returns error message details for failed messages', async () => {
239261
const errorMsg = {
240262
...baseMessage,

packages/backend/src/analytics/services/message-details.service.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ export interface MessageDetailResponse {
3333
fallback_from_model: string | null;
3434
fallback_index: number | null;
3535
session_key: string | null;
36+
feedback_rating: string | null;
37+
feedback_tags: string[] | null;
38+
feedback_details: string | null;
3639
};
3740
llm_calls: {
3841
id: string;
@@ -149,6 +152,9 @@ export class MessageDetailsService {
149152
fallback_from_model: message.fallback_from_model,
150153
fallback_index: message.fallback_index,
151154
session_key: message.session_key,
155+
feedback_rating: message.feedback_rating,
156+
feedback_tags: message.feedback_tags ? message.feedback_tags.split(',') : null,
157+
feedback_details: message.feedback_details,
152158
},
153159
llm_calls: llmCalls.map((lc) => ({
154160
id: lc.id,

0 commit comments

Comments
 (0)