From a9e7d248f4afeb0535129a7d5e8be36792055264 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 18 Aug 2026 12:05:03 +0200 Subject: [PATCH 1/2] feat(sessions): add shared sessions list filter Add an All/Shared filter on the Sessions page, with copy-link and unshare actions for published sessions. --- .../cloud/sessions/SessionsPageContent.tsx | 133 ++++++++++++++---- .../components/cloud-agent/SessionsList.tsx | 11 ++ .../web/src/lib/session-ingest-client.test.ts | 72 ++++++++++ apps/web/src/lib/session-ingest-client.ts | 36 +++++ .../routers/cli-sessions-v2-router.test.ts | 107 ++++++++++++++ .../web/src/routers/cli-sessions-v2-router.ts | 42 ++++++ 6 files changed, 376 insertions(+), 25 deletions(-) diff --git a/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx b/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx index 672deb8961..cbf402e8ef 100644 --- a/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx +++ b/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx @@ -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 { @@ -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 { @@ -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('all'); - const [includeSubSessions, setIncludeSubSessions] = useState(false); + const [sessionFilter, setSessionFilter] = useState('all'); + const [pendingSessionId, setPendingSessionId] = useState(null); type SessionWithSource = SessionsListItem & { source: 'v2' }; const [selectedSession, setSelectedSession] = useState(null); const [isDialogOpen, setIsDialogOpen] = useState(false); @@ -72,42 +78,83 @@ 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 handleCopyLink = async (sessionId: string) => { + setPendingSessionId(sessionId); + 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 { + setPendingSessionId(null); + } + }; + + 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; + } + + setPendingSessionId(sessionId); + try { + await unshareMutation.mutateAsync({ session_id: sessionId }); + } catch { + toast.error('Could not unshare the session'); + } finally { + setPendingSessionId(null); + } + }; + // Convert API session to StoredSession format const convertToStoredSession = (session: { session_id: string; @@ -182,15 +229,15 @@ export function SessionsPageContent() { @@ -204,11 +251,47 @@ export function SessionsPageContent() { ) : sessions.length === 0 ? (

- {isSearching ? 'No sessions found matching your search.' : 'No sessions yet.'} + {isSearching + ? 'No sessions found matching your search.' + : sharedOnly + ? 'No shared sessions.' + : 'No sessions yet.'}

) : ( - + { + const isPending = pendingSessionId === session.sessionId; + return ( + <> + + + + ); + } + : undefined + } + /> )} {/* Open In Dialog */} diff --git a/apps/web/src/components/cloud-agent/SessionsList.tsx b/apps/web/src/components/cloud-agent/SessionsList.tsx index 3c9dd97274..8a00323417 100644 --- a/apps/web/src/components/cloud-agent/SessionsList.tsx +++ b/apps/web/src/components/cloud-agent/SessionsList.tsx @@ -15,12 +15,14 @@ export type SessionsListProps = { sessions: T[]; organizationId?: string; onSessionClick?: (session: T) => void; + rowActions?: (session: T) => React.ReactNode; }; export function SessionsList({ sessions, organizationId, onSessionClick, + rowActions, }: SessionsListProps) { if (sessions.length === 0) { return ( @@ -121,6 +123,15 @@ export function SessionsList({ )} + {rowActions ? ( +
event.stopPropagation()} + onKeyDown={event => event.stopPropagation()} + > + {rowActions(session)} +
+ ) : null} diff --git a/apps/web/src/lib/session-ingest-client.test.ts b/apps/web/src/lib/session-ingest-client.test.ts index 3059a142f9..958b04e203 100644 --- a/apps/web/src/lib/session-ingest-client.test.ts +++ b/apps/web/src/lib/session-ingest-client.test.ts @@ -7,6 +7,7 @@ import { fetchSessionMessagesPage, deleteSession, shareSession, + unshareSession, fetchSharedSessionMetadata, invalidateOrganizationSessionAccess, } from './session-ingest-client'; @@ -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 // --------------------------------------------------------------------------- diff --git a/apps/web/src/lib/session-ingest-client.ts b/apps/web/src/lib/session-ingest-client.ts index b92d2c37f9..f604122c52 100644 --- a/apps/web/src/lib/session-ingest-client.ts +++ b/apps/web/src/lib/session-ingest-client.ts @@ -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 { + 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 diff --git a/apps/web/src/routers/cli-sessions-v2-router.test.ts b/apps/web/src/routers/cli-sessions-v2-router.test.ts index 446ba9a51d..37ea49d4d3 100644 --- a/apps/web/src/routers/cli-sessions-v2-router.test.ts +++ b/apps/web/src/routers/cli-sessions-v2-router.test.ts @@ -2732,6 +2732,113 @@ describe('cli-sessions-v2-router', () => { }); }); + describe('list / search sharedOnly', () => { + const sharedId = 'ses_shared_only_published_0001'; + const unsharedId = 'ses_shared_only_private_0001'; + const sharedPublicId = '11111111-1111-4111-8111-111111111111'; + + beforeEach(async () => { + await db.insert(cli_sessions_v2).values([ + { + session_id: sharedId, + kilo_user_id: regularUser.id, + created_on_platform: 'cli', + title: 'published session', + public_id: sharedPublicId, + }, + { + session_id: unsharedId, + kilo_user_id: regularUser.id, + created_on_platform: 'cli', + title: 'private session', + }, + ]); + }); + + afterEach(async () => { + await db + .delete(cli_sessions_v2) + .where(inArray(cli_sessions_v2.session_id, [sharedId, unsharedId])); + }); + + it('list with sharedOnly returns only sessions that have a public_id', async () => { + const caller = await createCallerForUser(regularUser.id); + const result = await caller.cliSessionsV2.list({ sharedOnly: true }); + const ids = result.cliSessions.map(session => session.session_id); + + expect(ids).toContain(sharedId); + expect(ids).not.toContain(unsharedId); + expect( + result.cliSessions.find(session => session.session_id === sharedId) + ).not.toHaveProperty('public_id'); + }); + + it('search with sharedOnly returns only sessions that have a public_id', async () => { + const caller = await createCallerForUser(regularUser.id); + const result = await caller.cliSessionsV2.search({ + search_string: 'session', + sharedOnly: true, + }); + const ids = result.results.map(session => session.session_id); + + expect(ids).toContain(sharedId); + expect(ids).not.toContain(unsharedId); + expect(result.results.find(session => session.session_id === sharedId)).not.toHaveProperty( + 'public_id' + ); + }); + }); + + describe('unshare', () => { + const sessionId = 'ses_unshare_owner_only_0001'; + + beforeEach(async () => { + await db.insert(cli_sessions_v2).values({ + session_id: sessionId, + kilo_user_id: regularUser.id, + created_on_platform: 'cli', + title: 'shared session', + public_id: '22222222-2222-4222-8222-222222222222', + }); + }); + + afterEach(async () => { + await db.delete(cli_sessions_v2).where(eq(cli_sessions_v2.session_id, sessionId)); + }); + + it('is owner-only and maps worker 404 to not found', async () => { + const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ success: false, error: 'session_not_found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + try { + const otherCaller = await createCallerForUser(otherUser.id); + await expect( + otherCaller.cliSessionsV2.unshare({ session_id: sessionId }) + ).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + expect(fetchSpy).not.toHaveBeenCalled(); + + const ownerCaller = await createCallerForUser(regularUser.id); + await expect( + ownerCaller.cliSessionsV2.unshare({ session_id: sessionId }) + ).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + expect(fetchSpy).toHaveBeenCalledWith( + `https://test-ingest.example.com/api/session/${encodeURIComponent(sessionId)}/unshare`, + expect.objectContaining({ method: 'POST' }) + ); + } finally { + fetchSpy.mockRestore(); + } + }); + }); + describe('rename CLI notify', () => { const sessionId = 'ses_rename_notify_test_abc12'; diff --git a/apps/web/src/routers/cli-sessions-v2-router.ts b/apps/web/src/routers/cli-sessions-v2-router.ts index 152444163d..911dafe0bb 100644 --- a/apps/web/src/routers/cli-sessions-v2-router.ts +++ b/apps/web/src/routers/cli-sessions-v2-router.ts @@ -31,6 +31,7 @@ import { fetchSessionMessagesPage, deleteSession as deleteSessionIngest, shareSession as shareSessionIngest, + unshareSession as unshareSessionIngest, } from '@/lib/session-ingest-client'; import { DEFAULT_KILO_SDK_MESSAGE_PAGE_SIZE, @@ -512,6 +513,7 @@ const ListSessionsInputSchema = z.object({ limit: z.number().min(1).max(RECENT_DAYS_LIMIT).optional().default(PAGE_SIZE), orderBy: z.enum(['created_at', 'updated_at']).optional().default('updated_at'), includeChildren: z.boolean().optional().default(false), + sharedOnly: z.boolean().optional().default(false), createdOnPlatform: z .union([createdOnPlatformField, z.array(createdOnPlatformField).min(1)]) .optional(), @@ -538,6 +540,7 @@ const SearchInputSchema = z.object({ .optional(), organizationId: z.uuid().nullable().optional(), includeChildren: z.boolean().optional().default(false), + sharedOnly: z.boolean().optional().default(false), gitUrl: z.union([z.string(), z.array(z.string()).min(1)]).optional(), }); @@ -728,6 +731,7 @@ export const cliSessionsV2Router = createTRPCRouter({ limit, orderBy, includeChildren, + sharedOnly, createdOnPlatform, organizationId, gitUrl, @@ -753,6 +757,10 @@ export const cliSessionsV2Router = createTRPCRouter({ whereConditions.push(isNull(cli_sessions_v2.parent_session_id)); } + if (sharedOnly) { + whereConditions.push(isNotNull(cli_sessions_v2.public_id)); + } + if (updatedSince) { whereConditions.push(gte(cli_sessions_v2.updated_at, updatedSince)); } @@ -814,6 +822,7 @@ export const cliSessionsV2Router = createTRPCRouter({ createdOnPlatform, organizationId, includeChildren, + sharedOnly, gitUrl, } = input; @@ -833,6 +842,10 @@ export const cliSessionsV2Router = createTRPCRouter({ whereConditions.push(isNull(cli_sessions_v2.parent_session_id)); } + if (sharedOnly) { + whereConditions.push(isNotNull(cli_sessions_v2.public_id)); + } + // Use position() for a case-insensitive substring match. This avoids LIKE // wildcard semantics entirely, so %, _, and \ in user input are matched // literally without any escaping dance. @@ -1663,6 +1676,35 @@ export const cliSessionsV2Router = createTRPCRouter({ } }), + /** + * Revoke a V2 session's public share link. + * + * Owner-only, matching the session-ingest worker. A missing or inaccessible + * session is NOT_FOUND. Worker 404 is mapped the same way. + */ + unshare: baseProcedure.input(ShareSessionInputSchema).mutation(async ({ ctx, input }) => { + const { session_id } = input; + await getSessionWithAccessCheck(session_id, ctx); + + try { + await unshareSessionIngest(session_id, ctx.user.id); + return { success: true as const }; + } catch (error) { + if (error instanceof Error && error.message === 'Session not found') { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Session not found' }); + } + captureException(error, { + tags: { source: 'cli-sessions-v2-router', endpoint: 'unshare' }, + extra: { session_id }, + }); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to unshare session', + cause: error, + }); + } + }), + /** * Share a v2 CLI session from a webhook trigger request. * Creates a read-only public snapshot via the session-ingest worker. From 4d30cbc0bb31d57f292122eb0f8358cedeaeff1f Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 18 Aug 2026 12:12:54 +0200 Subject: [PATCH 2/2] fix(sessions): track overlapping row-action pending state A single pendingSessionId slot cleared the first-finishing action and re-enabled another row while its copy/unshare was still in flight. --- .../cloud/sessions/SessionsPageContent.tsx | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx b/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx index cbf402e8ef..9cfe83a819 100644 --- a/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx +++ b/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx @@ -58,7 +58,7 @@ export function SessionsPageContent() { const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); const [platformFilter, setPlatformFilter] = useState('all'); const [sessionFilter, setSessionFilter] = useState('all'); - const [pendingSessionId, setPendingSessionId] = useState(null); + const [pendingSessionIds, setPendingSessionIds] = useState(() => new Set()); type SessionWithSource = SessionsListItem & { source: 'v2' }; const [selectedSession, setSelectedSession] = useState(null); const [isDialogOpen, setIsDialogOpen] = useState(false); @@ -121,8 +121,20 @@ export function SessionsPageContent() { 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) => { - setPendingSessionId(sessionId); + setSessionPending(sessionId, true); try { const result = await shareMutation.mutateAsync({ session_id: sessionId }); await navigator.clipboard.writeText(`${window.location.origin}/s/${result.share_token}`); @@ -130,7 +142,7 @@ export function SessionsPageContent() { } catch { toast.error('Could not copy the share link'); } finally { - setPendingSessionId(null); + setSessionPending(sessionId, false); } }; @@ -145,13 +157,13 @@ export function SessionsPageContent() { return; } - setPendingSessionId(sessionId); + setSessionPending(sessionId, true); try { await unshareMutation.mutateAsync({ session_id: sessionId }); } catch { toast.error('Could not unshare the session'); } finally { - setPendingSessionId(null); + setSessionPending(sessionId, false); } }; @@ -265,7 +277,7 @@ export function SessionsPageContent() { rowActions={ sharedOnly ? session => { - const isPending = pendingSessionId === session.sessionId; + const isPending = pendingSessionIds.has(session.sessionId); return ( <>