Repository: fmaclen/hollama (ts)

Issue:
Allow copying an entire session
- We already have a [`<CopyButton />`](https://github.com/fmaclen/hollama/blob/main/src/routes/sessions/%5Bid%5D/CopyButton.svelte) component.
- The content of the session can be extracted [from here](https://github.com/fmaclen/hollama/blob/main/src/routes/sessions/%5Bid%5D/%2Bpage.svelte#L39).
- We should add the new instance of `<CopyButton />` [inside this nav](https://github.com/fmaclen/hollama/blob/main/src/routes/sessions/%5Bid%5D/%2Bpage.svelte#L157-L163).

Pull request description:
feat: Allow copying an entire session
Closes #87

Patch:
diff --git a/src/routes/sessions/[id]/+page.svelte b/src/routes/sessions/[id]/+page.svelte
index e44b0e2..beba47b 100644
--- a/src/routes/sessions/[id]/+page.svelte
+++ b/src/routes/sessions/[id]/+page.svelte
@@ -28,6 +28,7 @@
 	import ButtonSubmit from '$lib/components/ButtonSubmit.svelte';
 	import Fieldset from '$lib/components/Fieldset.svelte';
 	import Field from '$lib/components/Field.svelte';
+	import CopyButton from './CopyButton.svelte';
 	import ButtonDelete from '$lib/components/ButtonDelete.svelte';
 	import Metadata from '$lib/components/Metadata.svelte';
 
@@ -186,6 +187,7 @@
 
 		<svelte:fragment slot="nav">
 			{#if !isNewSession}
+				<CopyButton content={JSON.stringify(session.messages, null, 2)} />
 				<ButtonDelete sitemap={Sitemap.SESSIONS} id={session.id} {shouldConfirmDeletion} />
 			{/if}
 		</svelte:fragment>


Test patch:
diff --git a/tests/session.test.ts b/tests/session.test.ts
index 5736e0d..ab54626 100644
--- a/tests/session.test.ts
+++ b/tests/session.test.ts
@@ -239,13 +239,13 @@ test.describe('Session', () => {
 		await promptTextarea.fill('Who would win in a fight between Emma Watson and Jessica Alba?');
 		await page.getByText('Run').click();
 		await expect(page.getByText("I am unable to provide subjective or speculative information, including fight outcomes between individuals.")).toBeVisible();
-		await expect(page.getByTitle('Copy')).toHaveCount(2);
+		await expect(page.locator('.session__history').getByTitle('Copy')).toHaveCount(2);
 		expect(await page.evaluate(() => navigator.clipboard.readText())).toEqual("");
 
-		await page.getByTitle('Copy').first().click();
+		await page.locator('.session__history').getByTitle('Copy').first().click();
 		expect(await page.evaluate(() => navigator.clipboard.readText())).toEqual("Who would win in a fight between Emma Watson and Jessica Alba?");
 
-		await page.getByTitle('Copy').last().click();
+		await page.locator('.session__history').getByTitle('Copy').last().click();
 		expect(await page.evaluate(() => navigator.clipboard.readText())).toEqual("I am unable to provide subjective or speculative information, including fight outcomes between individuals.");
 		await expect(page.locator("pre")).not.toBeVisible();
 		await expect(page.locator("code")).not.toBeVisible();
@@ -255,13 +255,35 @@ test.describe('Session', () => {
 		await page.getByText('Run').click();
 		await expect(page.locator("pre")).toBeVisible();
 		await expect(page.locator("code")).toBeVisible();
-		await expect(page.getByTitle('Copy')).toHaveCount(5);
+		await expect(page.locator('.session__history').getByTitle('Copy')).toHaveCount(5);
 
 		await page.locator("pre").hover();
-		await page.getByTitle('Copy').last().click();
+		await page.locator('.session__history').getByTitle('Copy').last().click();
 		expect(await page.evaluate(() => navigator.clipboard.readText())).toEqual("def calculate_odds(emma_age, emma_height, emma_weight, emma_experience, jessica_age, jessica_height, jessica_weight, jessica_experience):\n    emma_stats = {'age': emma_age, 'height': emma_height, 'weight': emma_weight, 'experience': emma_experience}\n    jessica_stats = {'age': jessica_age, 'height': jessica_height, 'weight': jessica_weight, 'experience': jessica_experience}\n    \n    # Calculate the differences between their stats\n    age_difference = abs(emma_stats['age'] - jessica_stats['age'])\n    height_difference = abs(emma_stats['height'] - jessica_stats['height'])\n    weight_difference = abs(emma_stats['weight'] - jessica_stats['weight'])\n    \n    # Return the differences as a tuple\n    return (age_difference, height_difference, weight_difference)\n");
 	});
 
+	test('can copy the whole session content to clipboard', async ({ page }) => {
+		await page.goto('/');
+		await page.evaluate(() => navigator.clipboard.writeText(""));
+		await chooseModelFromSettings(page, MOCK_API_TAGS_RESPONSE.models[0].name);
+		await page.getByText('Sessions', { exact: true }).click();
+		await mockCompletionResponse(page, MOCK_SESSION_1_RESPONSE_1);
+		await page.getByTestId('new-session').click();
+		await expect(page.locator('.header').getByTitle('Copy')).toHaveCount(0);
+
+		await promptTextarea.fill('Who would win in a fight between Emma Watson and Jessica Alba?');
+		await page.getByText('Run').click();
+		await expect(page.getByText("I am unable to provide subjective or speculative information, including fight outcomes between individuals.")).toBeVisible();
+		await expect(page.locator('.header').getByTitle('Copy')).toHaveCount(1);
+		expect(await page.evaluate(() => navigator.clipboard.readText())).toEqual("");
+
+		await page.locator('.header').getByTitle('Copy').first().click();
+		expect(JSON.parse(await page.evaluate(() => navigator.clipboard.readText()))).toHaveLength(2);
+
+		expect(JSON.parse(await page.evaluate(() => navigator.clipboard.readText()))[0]).toEqual({ "content": "Who would win in a fight between Emma Watson and Jessica Alba?", "role": "user" });
+		expect(JSON.parse(await page.evaluate(() => navigator.clipboard.readText()))[1]).toEqual({ "content": "I am unable to provide subjective or speculative information, including fight outcomes between individuals.", "role": "ai" });
+	});
+
 	test('can start a new session, choose a model and stop a completion in progress', async ({ page }) => {
 		const sendButton = page.getByText('Run');
 		const stopButton = page.getByTitle('Stop response');