diff --git a/.plans/session-list-v2-migration.md b/.plans/session-list-v2-migration.md new file mode 100644 index 0000000000..aa125374b8 --- /dev/null +++ b/.plans/session-list-v2-migration.md @@ -0,0 +1,53 @@ +# Session List V2 Migration + +Switch the web cloud-agent session list from the unified session endpoint to the CLI session v2 endpoint, and ensure sessions are grouped by updated time. + +## Background + +All session list consumers currently call `unifiedSessions.list` — a tRPC procedure in `apps/web/src/routers/unified-sessions-router.ts` that runs a `UNION ALL` across both `cli_sessions` (v1) and `cli_sessions_v2` tables. + +A dedicated `cliSessionsV2.list` procedure already exists in `apps/web/src/routers/cli-sessions-v2-router.ts:229` that queries only `cli_sessions_v2`. + +For grouping, the sidebar in `ChatSidebar.tsx:49` already uses `session.updatedAt`, and all consumers pass `orderBy: 'updated_at'`. The schema default in `ListSessionsInputSchema` is still `'created_at'` though, which should be cleaned up. + +--- + +## Task 1: Switch session list to `cliSessionsV2.list` + +### Consumers to update + +| File | Line | Usage | +| ---------------------------------------------------------------------- | ---- | ----------------------------------- | +| `apps/web/src/components/cloud-agent-next/hooks/useSidebarSessions.ts` | 89 | `unifiedSessions.list` query | +| `apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx` | 78 | `unifiedSessions.list` query | +| `apps/web/src/components/cloud-agent/CloudSessionsPage.tsx` | 471 | `unifiedSessions.list` query | +| `apps/web/src/components/cloud-agent-next/CloudChatPage.tsx` | 151 | `unifiedSessions.list` invalidation | +| `apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx` | 138 | `unifiedSessions.list` invalidation | +| `apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx` | 653 | `unifiedSessions.list` invalidation | + +### Steps + +1. **Compare input/output schemas** between `unifiedSessions.list` and `cliSessionsV2.list` — identify field name or shape differences that consumers depend on (e.g., `source` field, column mapping, cursor format). +2. **Update each query consumer** to call `cliSessionsV2.list` instead of `unifiedSessions.list`. +3. **Update each invalidation consumer** to invalidate `cliSessionsV2.list` instead of `unifiedSessions.list`. +4. **Update search** — `unifiedSessions.search` is also a `UNION ALL`. Switch search callers to a v2-only equivalent. May need to add a `search` procedure to the v2 router if one doesn't exist. +5. **Update `recentRepositories`** — currently on the unified router, also a `UNION ALL`. Either add to v2 router or migrate. +6. **Typecheck** — run `pnpm typecheck` to catch schema mismatches from the migration. +7. **Consider deprecation** — decide whether to remove or keep the unified router for backward compatibility. + +--- + +## Task 2: Group sessions by updated time + +### Current state + +- `ChatSidebar.tsx:49` — `groupSessionsByDate` already groups by `session.updatedAt`. This is correct. +- All consumers already pass `orderBy: 'updated_at'` to the list query. +- The `ListSessionsInputSchema` default for `orderBy` is `'created_at'` (unified-sessions-router.ts:40). + +### Steps + +1. **Audit all `createdAt`/`created_at` usage** in session-related ordering or display code. +2. **Change the default `orderBy`** in `ListSessionsInputSchema` from `'created_at'` to `'updated_at'` — and similarly in the v2 router input schema if applicable. +3. **Verify** `SessionsPageContent` and other full-page session views aren't sorting by `createdAt`. +4. **Verify** the v2 router's `list` procedure supports `orderBy: 'updated_at'` and uses it correctly. 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 7e993cb6a3..7fd55b7961 100644 --- a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx @@ -148,7 +148,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 85fbe12414..358139551f 100644 --- a/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx +++ b/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx @@ -383,7 +383,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, }) @@ -650,7 +650,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', @@ -682,7 +682,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 97bd1e8acb..bf3af1a0b8 100644 --- a/apps/web/src/components/cloud-agent/CloudSessionsPage.tsx +++ b/apps/web/src/components/cloud-agent/CloudSessionsPage.tsx @@ -468,7 +468,7 @@ export function CloudSessionsPage({ organizationId }: CloudSessionsPageProps) { // Invalidate the sessions list cache so the sidebar shows the new session void queryClient.invalidateQueries({ - queryKey: trpc.unifiedSessions.list.queryKey({ + queryKey: trpc.cliSessionsV2.list.queryKey({ limit: 3, createdOnPlatform: ['cloud-agent', 'cloud-agent-web'], orderBy: 'updated_at', @@ -497,7 +497,7 @@ export function CloudSessionsPage({ organizationId }: CloudSessionsPageProps) { selectedPlatform, selectedRepo, selectedProfile, - trpc.unifiedSessions.list, + trpc.cliSessionsV2.list, trpcClient, ]); diff --git a/apps/web/src/routers/cli-sessions-v2-router.ts b/apps/web/src/routers/cli-sessions-v2-router.ts index 98069313a4..79b610294f 100644 --- a/apps/web/src/routers/cli-sessions-v2-router.ts +++ b/apps/web/src/routers/cli-sessions-v2-router.ts @@ -104,7 +104,7 @@ async function getSessionWithOwnerCheck(sessionId: string, userId: string) { const ListSessionsInputSchema = z.object({ cursor: z.iso.datetime().optional(), limit: z.number().min(1).max(50).optional().default(PAGE_SIZE), - orderBy: z.enum(['created_at', 'updated_at']).optional().default('created_at'), + orderBy: z.enum(['created_at', 'updated_at']).optional().default('updated_at'), includeChildren: z.boolean().optional().default(false), createdOnPlatform: z .union([createdOnPlatformField, z.array(createdOnPlatformField).min(1)]) @@ -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,63 @@ 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)); + } + + // Escape ILIKE wildcard characters so literal %, _ in user input are matched exactly + const escaped = search_string.replace(/[%_]/g, '\\$&'); + + whereConditions.push( + sql`(COALESCE(${cli_sessions_v2.title}, '') ILIKE ${`%${escaped}%`} OR ${cli_sessions_v2.session_id}::text ILIKE ${`%${escaped}%`})` + ); + + 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({