Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src-tauri/src/commands/feedback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,7 @@ mod tests {
let disabled = runtime_config_with_feedback(Some(RuntimeFeedbackConfig {
enabled: Some(false),
project_key: Some("CUSTOM".to_string()),
response_rating_enabled: None,
}));
assert!(!feedback_enabled(&disabled));
assert_eq!(feedback_project_key(&disabled), "CUSTOM");
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/commands/runtime_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ pub struct RuntimeFeedbackConfig {
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub project_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub response_rating_enabled: Option<bool>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
Expand Down Expand Up @@ -1167,6 +1169,7 @@ mod tests {
feedback: Some(RuntimeFeedbackConfig {
enabled: Some(true),
project_key: Some("BOT".to_string()),
response_rating_enabled: Some(true),
}),
kgoose: Some(RuntimeKgooseConfig {
base_url: Some("https://kgoose.example.test".to_string()),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { act, render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { feedbackSurveySink } from "./feedbackSurveySink";
import { ResponseFeedbackControls } from "./ResponseFeedbackControls";

vi.mock("./feedbackSurveySink", () => ({ feedbackSurveySink: vi.fn() }));

const sink = vi.mocked(feedbackSurveySink);
let intersectionCallback: IntersectionObserverCallback;

class MockIntersectionObserver {
constructor(callback: IntersectionObserverCallback) {
intersectionCallback = callback;
}
observe() {}
disconnect() {}
}

describe("ResponseFeedbackControls", () => {
beforeEach(() => {
localStorage.clear();
sink.mockClear();
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
});

it("records an appearance when persistent controls enter the viewport", () => {
render(
<ResponseFeedbackControls
sessionId="session"
messageId="message"
persistentlyVisible
/>,
);

expect(sink).not.toHaveBeenCalled();
act(() => {
intersectionCallback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
);
});
expect(sink).toHaveBeenCalledWith(
expect.objectContaining({ eventType: "appeared" }),
);
});

it("does not record hidden controls merely because they are mounted", () => {
render(
<ResponseFeedbackControls
sessionId="session"
messageId="message"
persistentlyVisible={false}
/>,
);

expect(sink).not.toHaveBeenCalled();
});
});
100 changes: 100 additions & 0 deletions src/features/chat/response-feedback/ResponseFeedbackControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useEffect, useRef, useState } from "react";
import { ThumbsDown, ThumbsUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/shared/lib/cn";
import { MessageAction } from "@/shared/ui/ai-elements/message";
import {
getResponseFeedbackSelection,
markResponseFeedbackAppeared,
setResponseFeedbackSelection,
type ResponseFeedbackSelection,
} from "./responseFeedbackState";

interface ResponseFeedbackControlsProps {
sessionId: string;
messageId: string;
persistentlyVisible: boolean;
}

export function ResponseFeedbackControls({
sessionId,
messageId,
persistentlyVisible,
}: ResponseFeedbackControlsProps) {
const { t } = useTranslation("chat");
const controlsRef = useRef<HTMLSpanElement>(null);
const [selection, setSelection] = useState<ResponseFeedbackSelection | null>(
() => getResponseFeedbackSelection(sessionId, messageId),
);

useEffect(() => {
setSelection(getResponseFeedbackSelection(sessionId, messageId));
}, [messageId, sessionId]);

useEffect(() => {
const target = controlsRef.current;
if (
!persistentlyVisible ||
!target ||
typeof IntersectionObserver === "undefined"
) {
return;
}
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
markResponseFeedbackAppeared(sessionId, messageId);
observer.disconnect();
}
});
observer.observe(target);
return () => observer.disconnect();
}, [messageId, persistentlyVisible, sessionId]);

const select = (requested: ResponseFeedbackSelection) => {
const current = getResponseFeedbackSelection(sessionId, messageId);
const next = current === requested ? null : requested;
setSelection(setResponseFeedbackSelection(sessionId, messageId, next));
};
const goodSelected = selection === "good";
const badSelected = selection === "bad";
const selectedClassName =
"bg-accent text-foreground hover:bg-accent active:bg-accent";

return (
<span
ref={controlsRef}
className="inline-flex"
onPointerEnter={() => markResponseFeedbackAppeared(sessionId, messageId)}
onFocusCapture={() => markResponseFeedbackAppeared(sessionId, messageId)}
>
<MessageAction
size="icon-xs"
variant="ghost"
className={cn(
"text-muted-foreground/80",
goodSelected && selectedClassName,
)}
label={t("message.responseFeedbackGood")}
tooltip={t("message.responseFeedbackGood")}
aria-pressed={goodSelected}
onClick={() => select("good")}
>
<ThumbsUp className="size-3.5" />
</MessageAction>
<MessageAction
size="icon-xs"
variant="ghost"
className={cn(
"text-muted-foreground/80",
badSelected && selectedClassName,
)}
label={t("message.responseFeedbackBad")}
tooltip={t("message.responseFeedbackBad")}
aria-pressed={badSelected}
onClick={() => select("bad")}
>
<ThumbsDown className="size-3.5" />
</MessageAction>
</span>
);
}
41 changes: 41 additions & 0 deletions src/features/chat/response-feedback/feedbackSurveyEvents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { feedbackSurveySink } from "./feedbackSurveySink";
import { sendFeedbackSurveyEvent } from "./feedbackSurveyEvents";

vi.mock("./feedbackSurveySink", () => ({ feedbackSurveySink: vi.fn() }));

const sink = vi.mocked(feedbackSurveySink);

describe("feedbackSurveyEvents", () => {
beforeEach(() => {
localStorage.clear();
sink.mockClear();
});

it("assigns a persistent session-wide event sequence", () => {
sendFeedbackSurveyEvent({
sessionId: "session",
messageId: "message",
appearanceId: "appearance",
surveyType: "response",
eventType: "appeared",
});
sendFeedbackSurveyEvent({
sessionId: "session",
messageId: "message",
appearanceId: "appearance",
surveyType: "response",
eventType: "responded",
response: "good",
});

expect(sink).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ eventSequence: 1 }),
);
expect(sink).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ eventSequence: 2 }),
);
});
});
38 changes: 38 additions & 0 deletions src/features/chat/response-feedback/feedbackSurveyEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
type FeedbackSurveySinkEvent,
feedbackSurveySink,
} from "./feedbackSurveySink";

export type FeedbackSurveyEventInput = Omit<
FeedbackSurveySinkEvent,
"eventSequence"
>;

const FEEDBACK_SEQUENCE_STORAGE_PREFIX = "berd:feedback-event-sequence:v1:";
const volatileSequences = new Map<string, number>();

function nextFeedbackEventSequence(sessionId: string): number {
const key = `${FEEDBACK_SEQUENCE_STORAGE_PREFIX}${sessionId}`;
let current = volatileSequences.get(key) ?? 0;
try {
const stored = Number(localStorage.getItem(key));
if (Number.isSafeInteger(stored) && stored > current) current = stored;
} catch {
// The in-memory counter still preserves ordering for this app process.
}
const next = current + 1;
volatileSequences.set(key, next);
try {
localStorage.setItem(key, String(next));
} catch {
// Persistence is best-effort; feedback delivery must not affect chat.
}
return next;
}

export function sendFeedbackSurveyEvent(input: FeedbackSurveyEventInput): void {
feedbackSurveySink({
...input,
eventSequence: nextFeedbackEventSequence(input.sessionId),
});
}
15 changes: 15 additions & 0 deletions src/features/chat/response-feedback/feedbackSurveySink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export type FeedbackSurveyEventType = "appeared" | "responded";
export type FeedbackSurveyResponse = "good" | "bad" | "cleared";

export interface FeedbackSurveySinkEvent {
sessionId: string;
messageId: string;
appearanceId: string;
surveyType: "response";
eventSequence: number;
eventType: FeedbackSurveyEventType;
response?: FeedbackSurveyResponse;
}

/** Distribution-owned transport seam; stock Berd intentionally sends nothing. */
export function feedbackSurveySink(_event: FeedbackSurveySinkEvent): void {}
46 changes: 46 additions & 0 deletions src/features/chat/response-feedback/responseFeedbackRows.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { selectResponseFeedbackRowIds } from "./responseFeedbackRows";

describe("selectResponseFeedbackRowIds", () => {
it("prefers the answer over companion rows", () => {
expect([
...selectResponseFeedbackRowIds([
{
kind: "message",
rowId: "message:assistant:companion-image",
messageId: "assistant",
responseStartMessageId: "assistant",
},
{
kind: "message",
rowId: "message:assistant:answer",
messageId: "assistant",
responseStartMessageId: "assistant",
},
{
kind: "message",
rowId: "message:assistant:companion-mcp-app",
messageId: "assistant",
responseStartMessageId: "assistant",
},
]),
]).toEqual(["message:assistant:answer"]);
});

it("uses one final host row when there is no answer row", () => {
expect([
...selectResponseFeedbackRowIds([
{
kind: "assistant-content-fragment",
rowId: "message:assistant:fragment-0",
messageId: "assistant",
},
{
kind: "assistant-content-fragment",
rowId: "message:assistant:fragment-1",
messageId: "assistant",
},
]),
]).toEqual(["message:assistant:fragment-1"]);
});
});
30 changes: 30 additions & 0 deletions src/features/chat/response-feedback/responseFeedbackRows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { TranscriptRowDescriptor } from "@/features/chat/transcript/projection";

type FeedbackRow = Pick<
TranscriptRowDescriptor,
"kind" | "messageId" | "responseStartMessageId" | "rowId"
>;

export function selectResponseFeedbackRowIds(
rows: readonly FeedbackRow[],
): ReadonlySet<string> {
const selectedByResponse = new Map<
string,
{ rowId: string; isAnswer: boolean }
>();
for (const row of rows) {
if (
(row.kind !== "message" && row.kind !== "assistant-content-fragment") ||
!row.messageId
) {
continue;
}
const responseId = row.responseStartMessageId ?? row.messageId;
const current = selectedByResponse.get(responseId);
const isAnswer = row.rowId.endsWith(":answer");
if (!current || isAnswer || !current.isAnswer) {
selectedByResponse.set(responseId, { rowId: row.rowId, isAnswer });
}
}
return new Set([...selectedByResponse.values()].map(({ rowId }) => rowId));
}
Loading