Skip to content

Commit 6589b8f

Browse files
fix(desktop): improve transcript copy and chat placement
Add a transcript copy action to the expanded transcript panel and anchor the floating chat panel to the right side of the note surface.
1 parent f783914 commit 6589b8f

4 files changed

Lines changed: 110 additions & 6 deletions

File tree

apps/desktop/src/chat/components/persistent-chat.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describe("PersistentChatPanel", () => {
6666
} as typeof ResizeObserver;
6767
});
6868

69-
it("anchors the floating panel to the bottom FAB line", async () => {
69+
it("anchors the floating panel to the bottom-right of the note surface", async () => {
7070
render(<TestHost />);
7171

7272
await screen.findByTestId("chat-view");
@@ -76,8 +76,8 @@ describe("PersistentChatPanel", () => {
7676

7777
await waitFor(() => {
7878
expect(resizeFrame?.className).toContain("items-end");
79-
expect(resizeFrame?.className).toContain("justify-center");
80-
expect(panel?.style.transformOrigin).toBe("bottom center");
79+
expect(resizeFrame?.className).toContain("justify-end");
80+
expect(panel?.style.transformOrigin).toBe("bottom right");
8181
});
8282
});
8383

apps/desktop/src/chat/components/persistent-chat.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ export function PersistentChatPanel({
228228
minHeight: "min(320px, calc(100% - 1rem))",
229229
maxWidth: "calc(100% - 2rem)",
230230
maxHeight: "calc(100% - 1rem)",
231-
transformOrigin: "bottom center",
231+
transformOrigin: "bottom right",
232232
};
233233

234234
const handleResizeStart = (
@@ -326,7 +326,7 @@ export function PersistentChatPanel({
326326
"pointer-events-auto relative flex h-full min-h-0",
327327
isExpanded
328328
? "items-stretch justify-center p-0"
329-
: "items-end justify-center p-4",
329+
: "items-end justify-end p-4",
330330
])}
331331
onClick={(event) => {
332332
if (!isExpanded && event.target === event.currentTarget) {
@@ -430,7 +430,7 @@ function getFloatingPanelStyle(
430430
return {
431431
width: `${clampedSize.width}px`,
432432
height: `${clampedSize.height}px`,
433-
transformOrigin: "bottom center",
433+
transformOrigin: "bottom right",
434434
};
435435
}
436436

apps/desktop/src/session/components/bottom-accessory/post-session.test.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,23 @@ const {
1414
useTranscriptScreenMock,
1515
useRunBatchMock,
1616
useListenerMock,
17+
useTranscriptExportSegmentsMock,
1718
runBatchMock,
1819
handleBatchFailedMock,
20+
writeTextMock,
21+
toastSuccessMock,
22+
toastErrorMock,
1923
} = vi.hoisted(() => ({
2024
audioPathMock: vi.fn(),
2125
useTranscriptScreenMock: vi.fn(),
2226
useRunBatchMock: vi.fn(),
2327
useListenerMock: vi.fn(),
28+
useTranscriptExportSegmentsMock: vi.fn(),
2429
runBatchMock: vi.fn(),
2530
handleBatchFailedMock: vi.fn(),
31+
writeTextMock: vi.fn(),
32+
toastSuccessMock: vi.fn(),
33+
toastErrorMock: vi.fn(),
2634
}));
2735

2836
vi.mock("@hypr/plugin-fs-sync", () => ({
@@ -44,6 +52,13 @@ vi.mock("@hypr/ui/components/ui/spinner", () => ({
4452
Spinner: () => <div data-testid="spinner" />,
4553
}));
4654

55+
vi.mock("@hypr/ui/components/ui/toast", () => ({
56+
sonnerToast: {
57+
success: toastSuccessMock,
58+
error: toastErrorMock,
59+
},
60+
}));
61+
4762
vi.mock("@hypr/ui/components/ui/tooltip", () => ({
4863
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
4964
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
@@ -73,6 +88,16 @@ vi.mock("~/session/components/note-input/transcript", () => ({
7388
Transcript: () => <div data-testid="transcript" />,
7489
}));
7590

91+
vi.mock("~/session/components/note-input/transcript/export-data", () => ({
92+
useTranscriptExportSegments: useTranscriptExportSegmentsMock,
93+
formatTranscriptExportSegments: (
94+
segments: Array<{ speaker: string | null; text: string }>,
95+
) =>
96+
segments
97+
.map((segment) => `${segment.speaker ?? "Speaker"}: ${segment.text}`)
98+
.join("\n\n"),
99+
}));
100+
76101
vi.mock("~/session/components/note-input/transcript/state", () => ({
77102
useTranscriptScreen: useTranscriptScreenMock,
78103
}));
@@ -97,6 +122,12 @@ describe("PostSessionAccessory", () => {
97122
beforeEach(() => {
98123
cleanup();
99124
vi.clearAllMocks();
125+
Object.defineProperty(navigator, "clipboard", {
126+
configurable: true,
127+
value: {
128+
writeText: writeTextMock,
129+
},
130+
});
100131

101132
audioPathMock.mockResolvedValue({
102133
status: "ok",
@@ -109,7 +140,15 @@ describe("PostSessionAccessory", () => {
109140
liveSegments: [],
110141
currentActive: false,
111142
});
143+
useTranscriptExportSegmentsMock.mockReturnValue({
144+
data: [
145+
{ speaker: "Alex", text: "We should ship this." },
146+
{ speaker: null, text: "Agreed." },
147+
],
148+
isLoading: false,
149+
});
112150

151+
writeTextMock.mockResolvedValue(undefined);
113152
runBatchMock.mockResolvedValue(undefined);
114153
useRunBatchMock.mockReturnValue(runBatchMock);
115154

@@ -141,6 +180,28 @@ describe("PostSessionAccessory", () => {
141180
expect(handleBatchFailedMock).not.toHaveBeenCalled();
142181
});
143182

183+
it("copies transcript text from the expanded transcript panel", async () => {
184+
render(
185+
<PostSessionAccessory
186+
sessionId="session-1"
187+
hasAudio
188+
hasTranscript
189+
isTranscriptExpanded
190+
/>,
191+
);
192+
193+
fireEvent.click(screen.getByRole("button", { name: "Copy transcript" }));
194+
195+
await waitFor(() => {
196+
expect(writeTextMock).toHaveBeenCalledWith(
197+
"Alex: We should ship this.\n\nSpeaker: Agreed.",
198+
);
199+
});
200+
expect(toastSuccessMock).toHaveBeenCalledWith(
201+
"Transcript copied to clipboard",
202+
);
203+
});
204+
144205
it("shows Regenerate button without upload or reserved height in empty panel", async () => {
145206
useTranscriptScreenMock.mockReturnValue({
146207
kind: "ready",

apps/desktop/src/session/components/bottom-accessory/post-session.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
CopyIcon,
23
Loader2Icon,
34
Pencil,
45
RefreshCw,
@@ -10,6 +11,7 @@ import { type ReactNode, useCallback, useRef } from "react";
1011
import { commands as fsSyncCommands } from "@hypr/plugin-fs-sync";
1112
import { Button } from "@hypr/ui/components/ui/button";
1213
import { Spinner } from "@hypr/ui/components/ui/spinner";
14+
import { sonnerToast } from "@hypr/ui/components/ui/toast";
1315
import {
1416
Tooltip,
1517
TooltipContent,
@@ -20,6 +22,10 @@ import { cn } from "@hypr/utils";
2022
import * as AudioPlayer from "~/audio-player";
2123
import { getEnhancerService } from "~/services/enhancer";
2224
import { Transcript } from "~/session/components/note-input/transcript";
25+
import {
26+
formatTranscriptExportSegments,
27+
useTranscriptExportSegments,
28+
} from "~/session/components/note-input/transcript/export-data";
2329
import { useTranscriptScreen } from "~/session/components/note-input/transcript/state";
2430
import { useListener } from "~/stt/contexts";
2531
import { isStoppedTranscriptionError, useRunBatch } from "~/stt/useRunBatch";
@@ -395,8 +401,19 @@ function TranscriptReadyPanel({
395401
}) {
396402
const scrollRef = useRef<HTMLDivElement>(null);
397403
const regenerate = useRegenerateTranscript(sessionId);
404+
const { data: transcriptSegments, isLoading: isTranscriptLoading } =
405+
useTranscriptExportSegments(sessionId);
398406
const { audioExists, deleteRecording, isDeletingRecording } =
399407
AudioPlayer.useAudioPlayer();
408+
const transcriptText = formatTranscriptExportSegments(transcriptSegments);
409+
const canCopyTranscript = transcriptText.length > 0 && !isTranscriptLoading;
410+
const handleCopyTranscript = useCallback(() => {
411+
if (!canCopyTranscript) {
412+
return;
413+
}
414+
415+
void copyTranscriptToClipboard(transcriptText);
416+
}, [canCopyTranscript, transcriptText]);
400417

401418
if (!isExpanded) {
402419
return null;
@@ -425,6 +442,22 @@ function TranscriptReadyPanel({
425442
<p>Coming soon</p>
426443
</TooltipContent>
427444
</Tooltip>
445+
<button
446+
type="button"
447+
onClick={handleCopyTranscript}
448+
disabled={!canCopyTranscript}
449+
aria-label="Copy transcript"
450+
className={cn([
451+
"flex items-center gap-1 rounded-full px-1.5 py-0.5",
452+
"text-[11px] font-medium text-neutral-500",
453+
"transition-colors hover:bg-neutral-200/60 hover:text-neutral-700",
454+
"disabled:cursor-not-allowed disabled:text-neutral-300",
455+
"disabled:hover:bg-transparent disabled:hover:text-neutral-300",
456+
])}
457+
>
458+
<CopyIcon size={10} />
459+
{isTranscriptLoading ? "Loading..." : "Copy"}
460+
</button>
428461
<button
429462
type="button"
430463
onClick={regenerate}
@@ -467,6 +500,16 @@ function TranscriptReadyPanel({
467500
);
468501
}
469502

503+
async function copyTranscriptToClipboard(text: string) {
504+
try {
505+
await navigator.clipboard.writeText(text);
506+
sonnerToast.success("Transcript copied to clipboard");
507+
} catch (error) {
508+
console.error("Failed to copy transcript", error);
509+
sonnerToast.error("Failed to copy transcript");
510+
}
511+
}
512+
470513
function TranscriptEmptyPanel({
471514
sessionId,
472515
hasAudio,

0 commit comments

Comments
 (0)