Skip to content
Merged
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
145 changes: 120 additions & 25 deletions apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/lib/trpc/utils';
import { Input } from '@/components/ui/input';
import {
Expand Down Expand Up @@ -29,6 +29,8 @@ import { CopyableCommand } from '@/components/CopyableCommand';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import { PageContainer } from '@/components/layouts/PageContainer';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm';

/** Platform filter options matching the badge logic in SessionsList */
const PLATFORM_OPTIONS: readonly {
Expand All @@ -45,14 +47,18 @@ const PLATFORM_OPTIONS: readonly {
];

type PlatformFilterValue = 'all' | 'cloud-agent' | 'cli' | 'agent-manager' | 'gastown' | 'other';
type SessionFilterValue = 'all' | 'shared';

export function SessionsPageContent() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const confirm = useConfirm();
const pathname = usePathname();
const [searchQuery, setSearchQuery] = useState('');
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
const [platformFilter, setPlatformFilter] = useState<PlatformFilterValue>('all');
const [includeSubSessions, setIncludeSubSessions] = useState(false);
const [sessionFilter, setSessionFilter] = useState<SessionFilterValue>('all');
const [pendingSessionIds, setPendingSessionIds] = useState(() => new Set<string>());
type SessionWithSource = SessionsListItem & { source: 'v2' };
const [selectedSession, setSelectedSession] = useState<SessionWithSource | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
Expand All @@ -72,42 +78,95 @@ export function SessionsPageContent() {
const shouldUsePageContainer = !organizationId;

const isSearching = debouncedSearchQuery.trim().length > 0;
const sharedOnly = sessionFilter === 'shared';
const createdOnPlatform =
platformFilter === 'all'
? undefined
: platformFilter === 'cloud-agent'
? ['cloud-agent', 'cloud-agent-web']
: platformFilter;

// Query for listing sessions (when not searching)
// Order by updated_at and filter by organization and platform
const { data: listData, isLoading: isListLoading } = useQuery(
trpc.cliSessionsV2.list.queryOptions({
limit: 50,
orderBy: 'updated_at',
organizationId: organizationId ?? null,
createdOnPlatform:
platformFilter === 'all'
? undefined
: platformFilter === 'cloud-agent'
? ['cloud-agent', 'cloud-agent-web']
: platformFilter,
includeChildren: includeSubSessions,
createdOnPlatform,
includeChildren: false,
sharedOnly,
})
);

// Query for searching sessions (uses debounced value)
const { data: searchData, isLoading: isSearchLoading } = useQuery({
...trpc.cliSessionsV2.search.queryOptions({
search_string: debouncedSearchQuery.trim(),
limit: 50,
offset: 0,
organizationId: organizationId ?? null,
createdOnPlatform:
platformFilter === 'all'
? undefined
: platformFilter === 'cloud-agent'
? ['cloud-agent', 'cloud-agent-web']
: platformFilter,
includeChildren: includeSubSessions,
createdOnPlatform,
includeChildren: false,
sharedOnly,
}),
enabled: isSearching,
});

const unshareMutation = useMutation(
trpc.cliSessionsV2.unshare.mutationOptions({
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: trpc.cliSessionsV2.list.queryKey() });
void queryClient.invalidateQueries({ queryKey: trpc.cliSessionsV2.search.queryKey() });
},
})
);

const shareMutation = useMutation(trpc.cliSessionsV2.share.mutationOptions());

const setSessionPending = (sessionId: string, pending: boolean) => {
setPendingSessionIds(prev => {
const next = new Set(prev);
if (pending) {
next.add(sessionId);
} else {
next.delete(sessionId);
}
return next;
});
};

const handleCopyLink = async (sessionId: string) => {
setSessionPending(sessionId, true);
try {
const result = await shareMutation.mutateAsync({ session_id: sessionId });
await navigator.clipboard.writeText(`${window.location.origin}/s/${result.share_token}`);
toast.success('Link copied to clipboard');
} catch {
toast.error('Could not copy the share link');
} finally {
setSessionPending(sessionId, false);
}
};

const handleUnshare = async (sessionId: string) => {
const confirmed = await confirm({
title: 'Unshare session',
description: 'Anyone with the link will lose access.',
confirmLabel: 'Unshare',
destructive: true,
});
if (!confirmed) {
return;
}

setSessionPending(sessionId, true);
try {
await unshareMutation.mutateAsync({ session_id: sessionId });
} catch {
toast.error('Could not unshare the session');
} finally {
setSessionPending(sessionId, false);
}
};

// Convert API session to StoredSession format
const convertToStoredSession = (session: {
session_id: string;
Expand Down Expand Up @@ -182,15 +241,15 @@ export function SessionsPageContent() {
</SelectContent>
</Select>
<Select
value={includeSubSessions ? 'all' : 'root'}
onValueChange={value => setIncludeSubSessions(value === 'all')}
value={sessionFilter}
onValueChange={value => setSessionFilter(value as SessionFilterValue)}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="root">Root sessions</SelectItem>
<SelectItem value="all">All sessions</SelectItem>
<SelectItem value="all">All</SelectItem>
<SelectItem value="shared">Shared</SelectItem>
</SelectContent>
</Select>
</div>
Expand All @@ -204,11 +263,47 @@ export function SessionsPageContent() {
) : sessions.length === 0 ? (
<div className="py-12 text-center">
<p className="text-muted-foreground">
{isSearching ? 'No sessions found matching your search.' : 'No sessions yet.'}
{isSearching
? 'No sessions found matching your search.'
: sharedOnly
? 'No shared sessions.'
: 'No sessions yet.'}
</p>
</div>
) : (
<SessionsList sessions={sessions} onSessionClick={handleSessionClick} />
<SessionsList
sessions={sessions}
onSessionClick={handleSessionClick}
rowActions={
sharedOnly
? session => {
const isPending = pendingSessionIds.has(session.sessionId);
return (
<>
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending}
onClick={() => void handleCopyLink(session.sessionId)}
>
Copy link
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending}
onClick={() => void handleUnshare(session.sessionId)}
>
Unshare
</Button>
</>
);
}
: undefined
}
/>
)}

{/* Open In Dialog */}
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/components/cloud-agent/SessionsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ export type SessionsListProps<T extends SessionsListItem = SessionsListItem> = {
sessions: T[];
organizationId?: string;
onSessionClick?: (session: T) => void;
rowActions?: (session: T) => React.ReactNode;
};

export function SessionsList<T extends SessionsListItem>({
sessions,
organizationId,
onSessionClick,
rowActions,
}: SessionsListProps<T>) {
if (sessions.length === 0) {
return (
Expand Down Expand Up @@ -121,6 +123,15 @@ export function SessionsList<T extends SessionsListItem>({
</CardDescription>
)}
</div>
{rowActions ? (
<div
className="flex shrink-0 items-center gap-2"
onClick={event => event.stopPropagation()}
onKeyDown={event => event.stopPropagation()}
>
{rowActions(session)}
</div>
) : null}
</div>
</CardHeader>
<CardContent className="pt-0">
Expand Down
72 changes: 72 additions & 0 deletions apps/web/src/lib/session-ingest-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
fetchSessionMessagesPage,
deleteSession,
shareSession,
unshareSession,
fetchSharedSessionMetadata,
invalidateOrganizationSessionAccess,
} from './session-ingest-client';
Expand Down Expand Up @@ -410,6 +411,77 @@ describe('shareSession', () => {
});
});

// ---------------------------------------------------------------------------
// unshareSession
// ---------------------------------------------------------------------------

describe('unshareSession', () => {
beforeEach(() => {
mockFetch.mockReset();
mockCaptureException.mockReset();
mockGenerateInternalServiceToken.mockReset().mockReturnValue('mock-jwt-token');
});

it('resolves on success', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ success: true }),
});

await expect(unshareSession('ses_abc123', 'user_123')).resolves.toBeUndefined();
});

it('calls POST on the worker unshare path', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ success: true }),
});

await unshareSession('ses_abc123', 'user_123');

expect(mockFetch).toHaveBeenCalledWith(
'https://ingest.test.example.com/api/session/ses_abc123/unshare',
expect.objectContaining({ method: 'POST' })
);
});

it('throws on 404 without capturing', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
});

await expect(unshareSession('ses_nonexistent', 'user_123')).rejects.toThrow(
'Session not found'
);
expect(mockCaptureException).not.toHaveBeenCalled();
});

it('throws and calls captureException on 500', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
text: () => Promise.resolve('something broke'),
});

await expect(unshareSession('ses_abc123', 'user_123')).rejects.toThrow(
'Session ingest unshare failed: 500 Internal Server Error - something broke'
);

expect(mockCaptureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({
tags: { source: 'session-ingest-client', endpoint: 'unshare' },
extra: { sessionId: 'ses_abc123', status: 500 },
})
);
});
});

// ---------------------------------------------------------------------------
// fetchSharedSessionMetadata
// ---------------------------------------------------------------------------
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/lib/session-ingest-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,42 @@ export async function shareSession(
return { share_token: body.share_token };
}

/**
* Revoke a session's public share link via the session-ingest worker.
*
* Calls POST /session/:sessionId/unshare which clears `public_id`.
* Owner-only on the worker; a missing or inaccessible session is 404.
*/
export async function unshareSession(sessionId: string, userId: string): Promise<void> {
if (!SESSION_INGEST_WORKER_URL) {
throw new Error('SESSION_INGEST_WORKER_URL is not configured');
}

const token = generateInternalServiceToken(userId);
const url = `${SESSION_INGEST_WORKER_URL}/api/session/${encodeURIComponent(sessionId)}/unshare`;

const response = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});

if (response.status === 404) {
throw new Error('Session not found');
}

if (!response.ok) {
const errorText = await response.text().catch(() => '');
const error = new Error(
`Session ingest unshare failed: ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ''}`
);
captureException(error, {
tags: { source: 'session-ingest-client', endpoint: 'unshare' },
extra: { sessionId, status: response.status },
});
throw error;
}
}

/**
* Resolve the metadata for a public session share token without downloading
* the session snapshot. The token is intentionally never included in errors
Expand Down
Loading