Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
141 changes: 141 additions & 0 deletions apps/web/src/app/s/[sessionId]/page.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { notFound } from 'next/navigation';
import {
fetchSharedSessionMetadata,
fetchSharedSessionSnapshot,
} from '@/lib/session-ingest-client';
import SharedSessionPage from './page';

const mockFetchSharedSessionMetadata = jest.mocked(fetchSharedSessionMetadata);
const mockFetchSharedSessionSnapshot = jest.mocked(fetchSharedSessionSnapshot);
const mockNotFound = jest.mocked(notFound);

jest.mock('next/navigation', () => ({
notFound: jest.fn(() => {
throw new Error('NEXT_NOT_FOUND');
}),
}));

jest.mock('@/lib/constants', () => ({ APP_URL: 'https://app.test.example.com' }));
jest.mock('@/lib/session-ingest-client', () => ({
fetchSharedSessionMetadata: jest.fn(),
fetchSharedSessionSnapshot: jest.fn(),
}));
jest.mock('@/components/AnimatedLogo', () => ({ AnimatedLogo: () => null }));
jest.mock('@/app/share/[shareId]/open-in-cli-button', () => ({
OpenInCliButton: ({ command }: { command: string }) =>
React.createElement('button', null, command),
}));
jest.mock('@/app/share/[shareId]/open-in-editor-button', () => ({
OpenInEditorButton: ({ sessionId, pathOverride }: { sessionId: string; pathOverride: string }) =>
React.createElement('button', { 'data-session-id': sessionId, 'data-path': pathOverride }),
}));
jest.mock('./shared-session-transcript', () => ({
SharedSessionTranscript: ({ messages }: { messages: Array<{ info: { id: string } }> }) =>
React.createElement('div', { 'data-message-count': messages.length }),
}));
jest.mock('./shared-session-date', () => ({
SharedSessionDate: ({ isoDate }: { isoDate: string | null }) =>
isoDate ? React.createElement('span', null, 'formatted-date') : null,
}));

describe('SharedSessionPage', () => {
beforeEach(() => {
mockFetchSharedSessionMetadata.mockReset();
mockFetchSharedSessionSnapshot.mockReset();
mockNotFound.mockClear();
});

it('renders metadata, transcript, and keeps the share token in generated links', async () => {
const shareToken = 'eyJhbGciOiJIUzI1NiJ9.share.token';
mockFetchSharedSessionMetadata.mockResolvedValue({
title: 'A shared session',
ownerName: 'Ada Lovelace',
gitUrl: 'https://github.com/owner/repo.git',
gitBranch: 'main',
createdAt: '2026-08-19T12:00:00.000Z',
});
mockFetchSharedSessionSnapshot.mockResolvedValue({
info: { id: 'ses_1' },
messages: [
{
info: { id: 'msg_1', role: 'user', time: { created: 1 } },
parts: [{ id: 'p1', type: 'text', text: 'hello' }],
},
],
});

const html = renderToStaticMarkup(
await SharedSessionPage({ params: Promise.resolve({ sessionId: shareToken }) })
);

expect(mockFetchSharedSessionMetadata).toHaveBeenCalledWith(shareToken);
expect(mockFetchSharedSessionSnapshot).toHaveBeenCalledWith(shareToken);
expect(html).toContain('Shared by Ada Lovelace');
expect(html).toContain('A shared session');
expect(html).toContain('owner/repo');
expect(html).toContain('main');
expect(html).toContain('formatted-date');
expect(html).toContain('data-message-count="1"');
expect(html).toContain('kilo import https://app.test.example.com/s/' + shareToken);
expect(html).toContain('https://kilo.ai/install');
expect(html).toContain('Install Kilo');
expect(html).toContain(`data-session-id="${shareToken}"`);
expect(html).toContain(`data-path="/s/${shareToken}"`);
});

it('falls back to a shared-by heading when the session has no title', async () => {
const shareToken = 'untitled.share.token';
mockFetchSharedSessionMetadata.mockResolvedValue({
title: null,
ownerName: 'Grace Hopper',
gitUrl: null,
gitBranch: null,
createdAt: null,
});
mockFetchSharedSessionSnapshot.mockResolvedValue({
info: { id: 'ses_2' },
messages: [],
});

const html = renderToStaticMarkup(
await SharedSessionPage({ params: Promise.resolve({ sessionId: shareToken }) })
);

expect(html).toContain('Grace Hopper shared a session');
expect(html).not.toContain('Shared by Grace Hopper');
});

it('renders an empty transcript when the snapshot fetch fails', async () => {
const shareToken = 'snapshot.failure.token';
mockFetchSharedSessionMetadata.mockResolvedValue({
title: 'Still reachable',
ownerName: 'Ada Lovelace',
gitUrl: null,
gitBranch: null,
createdAt: null,
});
mockFetchSharedSessionSnapshot.mockRejectedValue(new Error('Session ingest snapshot failed'));

const html = renderToStaticMarkup(
await SharedSessionPage({ params: Promise.resolve({ sessionId: shareToken }) })
);

expect(html).toContain('Still reachable');
expect(html).toContain('data-message-count="0"');
});

it('uses not-found behavior when metadata cannot be resolved', async () => {
const shareToken = 'rejected.share.token';
mockFetchSharedSessionMetadata.mockResolvedValue(null);
mockFetchSharedSessionSnapshot.mockResolvedValue(null);

await expect(
SharedSessionPage({ params: Promise.resolve({ sessionId: shareToken }) })
).rejects.toThrow('NEXT_NOT_FOUND');

expect(mockFetchSharedSessionMetadata).toHaveBeenCalledWith(shareToken);
expect(mockNotFound).toHaveBeenCalledTimes(1);
});
});
81 changes: 0 additions & 81 deletions apps/web/src/app/s/[sessionId]/page.test.tsx

This file was deleted.

132 changes: 56 additions & 76 deletions apps/web/src/app/s/[sessionId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import React from 'react';
import { notFound } from 'next/navigation';
import { GitBranch } from 'lucide-react';
import { AnimatedLogo } from '@/components/AnimatedLogo';
import { CopyableCommand } from '@/components/CopyableCommand';
import { APP_URL } from '@/lib/constants';
import { fetchSharedSessionMetadata } from '@/lib/session-ingest-client';
import {
fetchSharedSessionMetadata,
fetchSharedSessionSnapshot,
} from '@/lib/session-ingest-client';
import { OpenInCliButton } from '@/app/share/[shareId]/open-in-cli-button';
import { OpenInEditorButton } from '@/app/share/[shareId]/open-in-editor-button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { SharedSessionDate } from './shared-session-date';
import { SharedSessionTranscript } from './shared-session-transcript';
import { formatRepoFromGitUrl } from './shared-session-meta';
import { toSharedTranscriptMessages } from './shared-transcript';

export const dynamic = 'force-dynamic';

Expand All @@ -15,91 +23,63 @@ export default async function SharedSessionPage({
params: Promise<{ sessionId: string }>;
}) {
const { sessionId: shareToken } = await params;
const session = await fetchSharedSessionMetadata(shareToken);
const [session, snapshot] = await Promise.all([
Comment thread
eshurakov marked this conversation as resolved.
Outdated
fetchSharedSessionMetadata(shareToken),
fetchSharedSessionSnapshot(shareToken).catch(() => null),
]);

if (!session) {
return notFound();
}

const shareUrl = `${APP_URL}/s/${shareToken}`;
const importCommand = `kilo import ${shareUrl}`;
const messages = snapshot ? toSharedTranscriptMessages(snapshot.messages) : [];
const ownerName = session.ownerName ?? 'Someone';
const repo = formatRepoFromGitUrl(session.gitUrl);

return (
<div className="bg-background relative min-h-screen overflow-hidden">
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute inset-0 bg-[radial-gradient(900px_500px_at_50%_0%,hsl(var(--primary)/0.16),transparent_60%)]" />
<div className="absolute inset-0 bg-[linear-gradient(to_right,hsl(var(--foreground))_1px,transparent_1px),linear-gradient(to_bottom,hsl(var(--foreground))_1px,transparent_1px)] bg-size-[72px_72px] opacity-[0.07]" />
<div className="via-border absolute inset-x-0 top-0 h-px bg-linear-to-r from-transparent to-transparent" />
</div>

<main className="mx-auto flex min-h-svh w-full max-w-3xl flex-col items-center justify-center px-4 py-12 sm:py-16">
<div className="flex w-full flex-col items-center gap-6 sm:gap-8">
<div className="animate-in fade-in slide-in-from-top-4 duration-500">
<AnimatedLogo />
</div>

<Card className="animate-in fade-in slide-in-from-bottom-4 w-full max-w-2xl duration-700">
<CardHeader className="items-center pb-4 text-center">
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">
{session.ownerName ?? 'Someone'} shared a session
</h1>
{session.title && (
<div className="text-muted-foreground mt-2 text-sm sm:text-base">
{session.title}
</div>
)}
</CardHeader>

<CardContent className="flex flex-col gap-4">
<div className="bg-muted/40 rounded-xl border p-5 shadow-sm">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-left">
<div className="text-sm font-medium">Open in Extension</div>
<div className="text-muted-foreground mt-1 text-xs">
Open this session directly in your editor.
</div>
</div>
<div className="flex justify-start sm:justify-end">
<OpenInEditorButton sessionId={shareToken} pathOverride={`/s/${shareToken}`} />
</div>
</div>
</div>

<div className="bg-muted/40 rounded-xl border p-5 shadow-sm">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-left">
<div className="text-sm font-medium">Import in CLI</div>
<div className="text-muted-foreground mt-1 text-xs">
Copy the command, then paste it in your terminal.
</div>
</div>
<div className="flex justify-start sm:justify-end">
<OpenInCliButton command={importCommand} />
</div>
</div>
<div className="bg-background min-h-screen">
<header className="border-border bg-background/80 sticky top-0 z-10 border-b backdrop-blur-sm">
<div className="mx-auto flex w-full max-w-3xl items-center justify-between px-4 py-2">
<AnimatedLogo />
<Button asChild variant="ghost" size="sm">
<a href="https://kilo.ai/install" target="_blank" rel="noopener noreferrer">
Install Kilo
</a>
</Button>
</div>
</header>

<div className="mt-3">
<CopyableCommand
command={importCommand}
className="bg-background/70 rounded-lg border px-3 py-2 text-sm shadow-sm"
/>
</div>
<main className="mx-auto flex w-full max-w-3xl flex-col gap-8 px-4 py-8">
<section className="border-border flex flex-col gap-4 border-b pb-8">
<div className="flex flex-col gap-1.5">
<h2 className="text-xl font-semibold tracking-tight">
{session.title ?? `${ownerName} shared a session`}
</h2>
{session.title && (
<p className="text-muted-foreground text-sm">Shared by {ownerName}</p>
)}
{(repo || session.gitBranch || session.createdAt) && (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{repo && <span>{repo}</span>}
{session.gitBranch && (
<span className="inline-flex items-center gap-1">
<GitBranch className="size-3.5" aria-hidden />
{session.gitBranch}
</span>
)}
<SharedSessionDate isoDate={session.createdAt} />
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<OpenInCliButton command={importCommand} variant="default" />
<OpenInEditorButton sessionId={shareToken} pathOverride={`/s/${shareToken}`} />
</div>
</section>

<div className="text-muted-foreground pt-2 text-center text-xs">
Need the CLI?{' '}
<a
href="https://kilo.ai/install"
target="_blank"
rel="noopener noreferrer"
className="text-foreground decoration-border hover:decoration-foreground underline underline-offset-4"
>
Install Kilo
</a>
</div>
</CardContent>
</Card>
</div>
<SharedSessionTranscript messages={messages} />
</main>
</div>
);
Expand Down
Loading