diff --git a/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx b/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx index 653f5d4216..5ad90fdc1f 100644 --- a/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx +++ b/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx @@ -25,7 +25,6 @@ import { } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { OpenInEditorButton } from '@/app/share/[shareId]/open-in-editor-button'; -import { OpenInCliButton } from '@/app/share/[shareId]/open-in-cli-button'; import { CopyableCommand } from '@/components/CopyableCommand'; import { usePathname } from 'next/navigation'; import Link from 'next/link'; @@ -53,7 +52,7 @@ export function SessionsPageContent() { const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); const [platformFilter, setPlatformFilter] = useState('all'); const [includeSubSessions, setIncludeSubSessions] = useState(false); - type SessionWithSource = SessionsListItem & { source: 'v1' | 'v2' }; + type SessionWithSource = SessionsListItem & { source: 'v2' }; const [selectedSession, setSelectedSession] = useState(null); const [isDialogOpen, setIsDialogOpen] = useState(false); @@ -76,7 +75,7 @@ export function SessionsPageContent() { // Query for listing sessions (when not searching) // Order by updated_at and filter by organization and platform const { data: listData, isLoading: isListLoading } = useQuery( - trpc.unifiedSessions.list.queryOptions({ + trpc.cliSessionsV2.list.queryOptions({ limit: 50, orderBy: 'updated_at', organizationId: organizationId ?? null, @@ -86,13 +85,13 @@ export function SessionsPageContent() { : platformFilter === 'cloud-agent' ? ['cloud-agent', 'cloud-agent-web'] : platformFilter, - includeSubSessions, + includeChildren: includeSubSessions, }) ); // Query for searching sessions (uses debounced value) const { data: searchData, isLoading: isSearchLoading } = useQuery({ - ...trpc.unifiedSessions.search.queryOptions({ + ...trpc.cliSessionsV2.search.queryOptions({ search_string: debouncedSearchQuery.trim(), limit: 50, offset: 0, @@ -103,7 +102,7 @@ export function SessionsPageContent() { : platformFilter === 'cloud-agent' ? ['cloud-agent', 'cloud-agent-web'] : platformFilter, - includeSubSessions, + includeChildren: includeSubSessions, }), enabled: isSearching, }); @@ -111,13 +110,12 @@ export function SessionsPageContent() { // Convert API session to StoredSession format const convertToStoredSession = (session: { session_id: string; - title: string; + title: string | null; git_url: string | null; created_at: string; updated_at: string; created_on_platform: string; cloud_agent_session_id: string | null; - source: 'v1' | 'v2'; }): SessionWithSource => { const repository = extractRepoFromGitUrl(session.git_url) ?? null; const prompt = session.title || 'Untitled'; @@ -129,7 +127,7 @@ export function SessionsPageContent() { repository, sessionId: session.session_id, mode: '', - source: session.source, + source: 'v2' as const, }; }; @@ -261,53 +259,24 @@ export function SessionsPageContent() { Fork this session to continue working on it in your editor or CLI

- {selectedSession.source === 'v1' && ( - <> - {/* Open in Editor (v1 only) */} -
- -
+ {/* Open in Editor */} +
+ +
- {/* Open in CLI (v1 only) */} -
- -
- - {/* Manual fork command (v1) */} -
-

- Or use the fork command manually: -

- -
- - )} - - {selectedSession.source === 'v2' && ( - <> - {/* Open in Editor (v2) */} -
- -
- - {/* Fork in CLI (v2) */} -
-

- Or use the CLI to fork this session: -

- -
- - )} + {/* Fork in CLI */} +
+

+ Or use the CLI to fork this session: +

+ +
)} diff --git a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx index 074dfbbe1c..466109606e 100644 --- a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx @@ -150,7 +150,7 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { useEffect(() => { if (prevActivityRef.current === 'busy' && activity.type === 'idle') { playCelebrationSound(); - void queryClient.invalidateQueries(trpc.unifiedSessions.list.pathFilter()); + void queryClient.invalidateQueries(trpc.cliSessionsV2.list.pathFilter()); } prevActivityRef.current = activity.type; }, [activity.type, playCelebrationSound, queryClient, trpc]); diff --git a/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx b/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx index 1580f3dbc2..50cd5c7098 100644 --- a/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx @@ -80,7 +80,7 @@ export function CloudSidebarLayout({ organizationId, children }: CloudSidebarLay const trpc = useTRPC(); const { data: recentReposData } = useQuery({ - ...trpc.unifiedSessions.recentRepositories.queryOptions({ + ...trpc.cliSessionsV2.recentRepositories.queryOptions({ organizationId, updatedSince: repoUpdatedSince, }), @@ -135,7 +135,7 @@ export function CloudSidebarLayout({ organizationId, children }: CloudSidebarLay toast.error('Failed to delete session'); } - void queryClient.invalidateQueries(trpc.unifiedSessions.list.pathFilter()); + void queryClient.invalidateQueries(trpc.cliSessionsV2.list.pathFilter()); refetchSessions(); }, [ @@ -155,8 +155,8 @@ export function CloudSidebarLayout({ organizationId, children }: CloudSidebarLay async (sessionId: string, title: string) => { await renameCliSessionV2({ session_id: sessionId, title }); renameSessionLocally(sessionId, title); - void queryClient.invalidateQueries(trpc.unifiedSessions.list.pathFilter()); - void queryClient.invalidateQueries(trpc.unifiedSessions.search.pathFilter()); + void queryClient.invalidateQueries(trpc.cliSessionsV2.list.pathFilter()); + void queryClient.invalidateQueries(trpc.cliSessionsV2.search.pathFilter()); refetchSessions(); }, [renameCliSessionV2, renameSessionLocally, queryClient, trpc, refetchSessions] diff --git a/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx b/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx index ea98dd5667..3def5082ff 100644 --- a/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx +++ b/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx @@ -427,7 +427,7 @@ export function NewSessionPanel({ organizationId }: NewSessionPanelProps) { const repoUpdatedSince = useMemo(() => startOfDay(subDays(new Date(), 5)).toISOString(), []); const { data: recentRepoData } = useQuery( - trpc.unifiedSessions.recentRepositories.queryOptions({ + trpc.cliSessionsV2.recentRepositories.queryOptions({ organizationId: organizationId ?? null, updatedSince: repoUpdatedSince, }) @@ -702,7 +702,7 @@ export function NewSessionPanel({ organizationId }: NewSessionPanelProps) { } void queryClient.invalidateQueries({ - queryKey: trpc.unifiedSessions.list.queryKey({ + queryKey: trpc.cliSessionsV2.list.queryKey({ limit: 3, createdOnPlatform: 'cloud-agent', orderBy: 'updated_at', @@ -734,7 +734,7 @@ export function NewSessionPanel({ organizationId }: NewSessionPanelProps) { selectedPlatform, selectedRepo, selectedProfile, - trpc.unifiedSessions.list, + trpc.cliSessionsV2.list, trpcClient, variant, ]); diff --git a/apps/web/src/components/cloud-agent-next/hooks/useSidebarSessions.ts b/apps/web/src/components/cloud-agent-next/hooks/useSidebarSessions.ts index e54e6b249c..c0b43f4640 100644 --- a/apps/web/src/components/cloud-agent-next/hooks/useSidebarSessions.ts +++ b/apps/web/src/components/cloud-agent-next/hooks/useSidebarSessions.ts @@ -1,7 +1,7 @@ /** * Hook for managing sidebar session list * - * Fetches sessions from the unified sessions router and maintains them in Jotai atoms + * Fetches sessions from the CLI sessions v2 router and maintains them in Jotai atoms * for reactive updates across the UI. Supports search and platform filtering. */ @@ -43,13 +43,13 @@ function dbSessionToStoredSession(session: DbSession | DbSessionV2): StoredSessi mode: v1?.last_mode ?? 'code', model: v1?.last_model ?? '', status: session.cloud_agent_session_id ? 'active' : 'completed', - createdAt: session.created_at.toISOString(), - updatedAt: session.updated_at.toISOString(), + createdAt: session.created_at, + updatedAt: session.updated_at, messages: [], cloudAgentSessionId: session.cloud_agent_session_id, createdOnPlatform: v1?.created_on_platform ?? null, sessionStatus: session.status, - sessionStatusUpdatedAt: session.status_updated_at?.toISOString() ?? null, + sessionStatusUpdatedAt: session.status_updated_at ?? null, }; } @@ -86,10 +86,10 @@ export function useSidebarSessions(options?: UseSidebarSessionsOptions): UseSide createdOnPlatform, gitUrl, }; - const listQueryKey = trpc.unifiedSessions.list.queryKey(listInput); + const listQueryKey = trpc.cliSessionsV2.list.queryKey(listInput); const { data: listData, isLoading: isListLoading } = useQuery({ - ...trpc.unifiedSessions.list.queryOptions(listInput), + ...trpc.cliSessionsV2.list.queryOptions(listInput), staleTime: 5000, enabled: !isSearchActive, }); @@ -98,7 +98,7 @@ export function useSidebarSessions(options?: UseSidebarSessionsOptions): UseSide const searchInput = { search_string: searchQuery, createdOnPlatform, organizationId, gitUrl }; const { data: searchData, isLoading: isSearchLoading } = useQuery({ - ...trpc.unifiedSessions.search.queryOptions(searchInput), + ...trpc.cliSessionsV2.search.queryOptions(searchInput), staleTime: 5000, enabled: isSearchActive, }); @@ -135,8 +135,8 @@ export function useSidebarSessions(options?: UseSidebarSessionsOptions): UseSide repository: extractRepoDisplay(row.git_url), branch: row.git_branch, prompt: row.title || `Session ${row.session_id.substring(0, 8)}`, - mode: row.last_mode ?? 'code', - model: row.last_model ?? '', + mode: 'code', + model: '', status: row.cloud_agent_session_id ? ('active' as const) : ('completed' as const), createdAt: row.created_at, updatedAt: row.updated_at, diff --git a/apps/web/src/components/cloud-agent-next/store/db-session-atoms.test.ts b/apps/web/src/components/cloud-agent-next/store/db-session-atoms.test.ts index d84816d4eb..2346c5e6fe 100644 --- a/apps/web/src/components/cloud-agent-next/store/db-session-atoms.test.ts +++ b/apps/web/src/components/cloud-agent-next/store/db-session-atoms.test.ts @@ -272,8 +272,8 @@ describe('getSessionDisplayTitle', () => { git_branch: null, cloud_agent_session_id: null, created_on_platform: 'unknown', - created_at: new Date(), - updated_at: new Date(), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), last_mode: null, last_model: null, version: 0, diff --git a/apps/web/src/components/cloud-agent-next/store/db-session-atoms.ts b/apps/web/src/components/cloud-agent-next/store/db-session-atoms.ts index 44c1d2dfb1..c2115bee1c 100644 --- a/apps/web/src/components/cloud-agent-next/store/db-session-atoms.ts +++ b/apps/web/src/components/cloud-agent-next/store/db-session-atoms.ts @@ -148,8 +148,8 @@ function getSessionStore(): MiniDb { // ============================================================================ /** - * API session type - matches the shape returned by cli-sessions-router.list (V1) - * Dates are returned as strings from the tRPC API + * API session type - matches the shape returned by cliSessionsV2 router list procedure. + * The cli_sessions_v2 schema uses `mode: 'string'` for timestamps, so dates are strings. */ type ApiSession = { session_id: string; @@ -160,16 +160,15 @@ type ApiSession = { created_on_platform: string; created_at: string; updated_at: string; - last_mode: string | null; - last_model: string | null; version: number; organization_id: string | null; status: string | null; status_updated_at: string | null; + parent_session_id: string | null; }; /** - * Database session type - with Date objects for convenient manipulation + * Database session type - timestamps are ISO strings (Drizzle `mode: 'string'`). */ export type DbSession = { session_id: string; @@ -178,40 +177,40 @@ export type DbSession = { git_branch: string | null; cloud_agent_session_id: string | null; created_on_platform: string; - created_at: Date; - updated_at: Date; + created_at: string; + updated_at: string; last_mode: string | null; last_model: string | null; version: number; organization_id: string | null; status: string | null; - status_updated_at: Date | null; + status_updated_at: string | null; }; /** - * Database session type for V2 - with Date objects + * Database session type for V2 - timestamps are ISO strings. * V2 sessions don't have git_url, organization_id, or mode/model fields */ export type DbSessionV2 = { session_id: string; title: string | null; cloud_agent_session_id: string | null; - created_at: Date; - updated_at: Date; + created_at: string; + updated_at: string; version: number; status: string | null; - status_updated_at: Date | null; + status_updated_at: string | null; }; /** - * Convert an API session (with string dates) to DbSession format (with Date objects) + * Convert an API session from the v2 router to DbSession format. + * Dates are already Date objects; last_mode/last_model are not present in v2. */ export function apiSessionToDbSession(apiSession: ApiSession): DbSession { return { ...apiSession, - created_at: new Date(apiSession.created_at), - updated_at: new Date(apiSession.updated_at), - status_updated_at: apiSession.status_updated_at ? new Date(apiSession.status_updated_at) : null, + last_mode: null, + last_model: null, }; } @@ -226,8 +225,8 @@ export type DbSessionDetails = { title: string | null; cloud_agent_session_id: string | null; organization_id: string | null; - created_at: Date; - updated_at: Date; + created_at: Date | string; + updated_at: Date | string; // V1-only fields (optional for V2 compatibility) kilo_user_id?: string; git_url?: string | null; diff --git a/apps/web/src/components/cloud-agent/CloudSessionsPage.tsx b/apps/web/src/components/cloud-agent/CloudSessionsPage.tsx index bf7562c8a2..618dd7a9ae 100644 --- a/apps/web/src/components/cloud-agent/CloudSessionsPage.tsx +++ b/apps/web/src/components/cloud-agent/CloudSessionsPage.tsx @@ -467,7 +467,11 @@ export function CloudSessionsPage({ organizationId }: CloudSessionsPageProps) { } } - // Invalidate the sessions list cache so the sidebar shows the new session + // Invalidate the sessions list cache so the sidebar shows the new session. + // This legacy page goes through cloudAgent.prepareSession which writes to + // cli_sessions (v1), so the sidebar/list data it produces still comes from + // the unified router (which UNIONs v1 and v2). Invalidating cliSessionsV2.list + // would miss the newly-created v1 row. void queryClient.invalidateQueries({ queryKey: trpc.unifiedSessions.list.queryKey({ limit: 3, diff --git a/apps/web/src/routers/cli-sessions-v2-router.ts b/apps/web/src/routers/cli-sessions-v2-router.ts index fe365ff124..5d51206f64 100644 --- a/apps/web/src/routers/cli-sessions-v2-router.ts +++ b/apps/web/src/routers/cli-sessions-v2-router.ts @@ -115,6 +115,18 @@ const ListSessionsInputSchema = z.object({ version: z.number().optional(), }); +const SearchInputSchema = z.object({ + search_string: z.string().min(1), + limit: z.number().min(1).max(50).optional().default(PAGE_SIZE), + offset: z.number().min(0).optional().default(0), + createdOnPlatform: z + .union([createdOnPlatformField, z.array(createdOnPlatformField).min(1)]) + .optional(), + organizationId: z.uuid().nullable().optional(), + includeChildren: z.boolean().optional().default(false), + gitUrl: z.union([z.string(), z.array(z.string()).min(1)]).optional(), +}); + const GetSessionInputSchema = z.object({ session_id: sessionIdField, }); @@ -291,6 +303,67 @@ export const cliSessionsV2Router = createTRPCRouter({ }; }), + /** + * Search sessions by title or session_id with ILIKE matching. + */ + search: baseProcedure.input(SearchInputSchema).query(async ({ ctx, input }) => { + const { + search_string, + limit, + offset, + createdOnPlatform, + organizationId, + includeChildren, + gitUrl, + } = input; + + const whereConditions: SQL[] = [eq(cli_sessions_v2.kilo_user_id, ctx.user.id)]; + + await addOrganizationCondition(whereConditions, ctx, organizationId); + addCreatedOnPlatformConditions(whereConditions, createdOnPlatform); + addGitUrlConditions(whereConditions, gitUrl); + + if (!includeChildren) { + whereConditions.push(isNull(cli_sessions_v2.parent_session_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. + const needle = search_string.toLowerCase(); + whereConditions.push( + sql`( + position(${needle} in lower(COALESCE(${cli_sessions_v2.title}, ''))) > 0 + OR position(${needle} in lower(${cli_sessions_v2.session_id}::text)) > 0 + )` + ); + + const baseWhere = and(...whereConditions); + + const [results, countResult] = await Promise.all([ + db + .select(commonSessionFields) + .from(cli_sessions_v2) + .where(baseWhere) + .orderBy(desc(cli_sessions_v2.updated_at)) + .limit(limit) + .offset(offset), + db + .select({ count: sql`COUNT(*)` }) + .from(cli_sessions_v2) + .where(baseWhere), + ]); + + const total = countResult.length > 0 ? Number(countResult[0].count) : 0; + + return { + results, + total, + limit, + offset, + }; + }), + recentRepositories: baseProcedure .input( z.object({ diff --git a/apps/web/src/routers/unified-sessions-router.ts b/apps/web/src/routers/unified-sessions-router.ts index a99d608a58..37a754c3ec 100644 --- a/apps/web/src/routers/unified-sessions-router.ts +++ b/apps/web/src/routers/unified-sessions-router.ts @@ -351,15 +351,21 @@ export const unifiedSessionsRouter = createTRPCRouter({ const v1Where = buildScopeFragments('cli_sessions', scopeOpts); const v2Where = buildScopeFragments('cli_sessions_v2', scopeOpts); - // Escape ILIKE wildcard characters so literal %, _ in user input are matched exactly - const escaped = search_string.replace(/[%_]/g, '\\$&'); - - // Search filter: ILIKE on title and session_id::text + // 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. + const needle = search_string.toLowerCase(); v1Where.push( - sql`(${cliSessions.title} ILIKE ${`%${escaped}%`} OR ${cliSessions.session_id}::text ILIKE ${`%${escaped}%`})` + sql`( + position(${needle} in lower(${cliSessions.title})) > 0 + OR position(${needle} in lower(${cliSessions.session_id}::text)) > 0 + )` ); v2Where.push( - sql`(COALESCE(${cli_sessions_v2.title}, '') ILIKE ${`%${escaped}%`} OR ${cli_sessions_v2.session_id}::text ILIKE ${`%${escaped}%`})` + sql`( + position(${needle} in lower(COALESCE(${cli_sessions_v2.title}, ''))) > 0 + OR position(${needle} in lower(${cli_sessions_v2.session_id}::text)) > 0 + )` ); const unionQuery = sql`