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({