Skip to content
Open
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 .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ env:
VITE_AUTOMATIONS: "0"
VITE_BUILDERBOT: "0"
VITE_FEEDBACK: "0"
VITE_FEEDBACK_SURVEYS: "0"
VITE_MANAGED_CONNECTIONS: "0"
VITE_TELEMETRY_ENFORCED: "0"
VITE_VOICE_DICTATION: "0"
Expand Down
1 change: 1 addition & 0 deletions scripts/build_linux_docker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ vite_env_names=(
VITE_AUTOMATIONS
VITE_BUILDERBOT
VITE_FEEDBACK
VITE_FEEDBACK_SURVEYS
VITE_MANAGED_CONNECTIONS
VITE_TELEMETRY_ENFORCED
VITE_VOICE_DICTATION
Expand Down
1 change: 1 addition & 0 deletions scripts/release/tests/release-scripts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,7 @@ describe("desktop release workflow platform gate", () => {
VITE_AUTOMATIONS: "0",
VITE_BUILDERBOT: "0",
VITE_FEEDBACK: "0",
VITE_FEEDBACK_SURVEYS: "0",
VITE_MANAGED_CONNECTIONS: "0",
VITE_VOICE_DICTATION: "0",
VITE_BYO_KEY_PROVIDERS: "1",
Expand Down
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 @@ -166,6 +166,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>,
Comment thread
comp615 marked this conversation as resolved.
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
Expand Down Expand Up @@ -1194,6 +1196,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
1 change: 1 addition & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ declare global {
readonly VITE_AUTOMATIONS?: string;
readonly VITE_BUILDERBOT?: string;
readonly VITE_FEEDBACK?: string;
readonly VITE_FEEDBACK_SURVEYS?: string;
readonly VITE_BYO_KEY_PROVIDERS?: string;
readonly VITE_TELEMETRY?: string;
readonly VITE_VOICE_DICTATION?: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { fireEvent, render, screen } 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);

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

it("records only user selections", () => {
render(
<ResponseFeedbackControls sessionId="session" messageId="message" />,
);

expect(sink).not.toHaveBeenCalled();

fireEvent.click(screen.getByRole("button", { name: /good/i }));

expect(sink).toHaveBeenCalledWith(
expect.objectContaining({
eventType: "responded",
response: "good",
}),
);
});

it("synchronizes the selected response across renderers", () => {
render(
<>
<ResponseFeedbackControls sessionId="session" messageId="message" />
<ResponseFeedbackControls sessionId="session" messageId="message" />
</>,
);

const goodButtons = screen.getAllByRole("button", { name: /good/i });
fireEvent.click(goodButtons[0]);
expect(goodButtons.every((button) => button.ariaPressed === "true")).toBe(
true,
);

fireEvent.click(goodButtons[1]);
expect(goodButtons.every((button) => button.ariaPressed === "false")).toBe(
true,
);
expect(sink.mock.calls.map(([event]) => event.response)).toEqual([
"good",
"cleared",
]);
});
});
75 changes: 75 additions & 0 deletions src/features/chat/response-feedback/ResponseFeedbackControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useCallback, useSyncExternalStore } 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,
setResponseFeedbackSelection,
subscribeResponseFeedbackSelection,
} from "./responseFeedbackState";

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

export function ResponseFeedbackControls({
sessionId,
messageId,
}: ResponseFeedbackControlsProps) {
const { t } = useTranslation("chat");
const subscribe = useCallback(
(onStoreChange: () => void) =>
subscribeResponseFeedbackSelection(sessionId, messageId, onStoreChange),
[messageId, sessionId],
);
const getSnapshot = useCallback(
() => getResponseFeedbackSelection(sessionId, messageId),
[messageId, sessionId],
);
const selection = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);

const select = (requested: "good" | "bad") => {
const current = getResponseFeedbackSelection(sessionId, messageId);
const next = current === requested ? null : requested;
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 className="inline-flex">
<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>
);
}
29 changes: 29 additions & 0 deletions src/features/chat/response-feedback/feedbackSurveyEvents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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(() => {
sink.mockClear();
});

it("forwards survey identity and state to the distribution-owned sink", () => {
const event = {
sessionId: "session",
messageId: "message",
appearanceId: "appearance",
surveyType: "response" as const,
eventType: "responded" as const,
response: "good" as const,
};

sendFeedbackSurveyEvent(event);

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

export type FeedbackSurveyEventInput = FeedbackSurveySinkEvent;

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

export interface FeedbackSurveySinkEvent {
sessionId: string;
messageId: string;
appearanceId: string;
surveyType: "response";
eventType: "responded";
response: FeedbackSurveyResponse;
}

/** Distribution-owned transport and ordering seam; stock Berd 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