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`