Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4bdeedc
fix: persist elapsed timer across session switches
KevinYoung-Kw Apr 15, 2026
1253308
fix: retain existing input text when selecting slash commands with op…
KevinYoung-Kw Apr 15, 2026
ae3764d
fix: place cursor at end of input after slash command selection
KevinYoung-Kw Apr 15, 2026
dabc370
refactor(timer): require startedAt prop and reset elapsed on change
KevinYoung-Kw Apr 15, 2026
356ae11
refactor(slash): extract splitAroundTrigger helper and guard env PORT…
KevinYoung-Kw Apr 15, 2026
566b793
feat: global search panel with sessions/messages/files support
KevinYoung-Kw Apr 15, 2026
1f45979
fix(global-search): match TYPE_ICONS keys to plural group names
KevinYoung-Kw Apr 15, 2026
df1255d
feat(search): global search UI overhaul with file and folder targeting
KevinYoung-Kw Apr 15, 2026
839955e
fix(search): stabilize dialog height and pin input at top
KevinYoung-Kw Apr 15, 2026
4e17f59
fix(search): responsive dialog height, remove bottom mask, fix a11y w…
KevinYoung-Kw Apr 15, 2026
2ca4c4d
feat(search): add subtle background and emphasis to message group hea…
KevinYoung-Kw Apr 15, 2026
49ee37b
Merge remote-tracking branch 'upstream/main' into feat/global-search
KevinYoung-Kw Apr 15, 2026
b675d2a
fix(file-tree): make search-driven expansion controlled and wait for …
KevinYoung-Kw Apr 15, 2026
5da44a1
fix(file-tree): prevent repeated scroll hijacking after tree auto-ref…
KevinYoung-Kw Apr 15, 2026
59de617
fix(search): harden global search UX and deep-link behavior
KevinYoung-Kw Apr 15, 2026
80a2b6a
fix(search): make repeated file deep-link seeking reliable
KevinYoung-Kw Apr 15, 2026
f8ad12c
fix(file-tree): harden cross-session deep-link seek behavior
KevinYoung-Kw Apr 15, 2026
4b849e6
test(search): add Playwright coverage for multi-mode global search UX
KevinYoung-Kw Apr 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/__tests__/e2e/global-search-file-seek.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { test, expect, type Page } from '@playwright/test';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

async function createSession(page: Page, title: string, workingDirectory: string) {
const res = await page.request.post('/api/chat/sessions', {
data: { title, working_directory: workingDirectory },
});
expect(res.ok()).toBeTruthy();
const data = await res.json();
return data.session.id as string;
}

test.describe('Global Search file deep-link seek UX', () => {
test('same-session repeat seek and cross-session seek both locate target file', async ({ page }) => {
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const rootA = path.join(os.tmpdir(), `codepilot-search-a-${suffix}`);
const rootB = path.join(os.tmpdir(), `codepilot-search-b-${suffix}`);
const fileA = path.join(rootA, 'src', 'feature-a', 'target-a.ts');
const fileB = path.join(rootB, 'src', 'feature-b', 'target-b.ts');

await fs.mkdir(path.dirname(fileA), { recursive: true });
await fs.mkdir(path.dirname(fileB), { recursive: true });
await fs.writeFile(fileA, 'export const targetA = 1;\n', 'utf8');
await fs.writeFile(fileB, 'export const targetB = 2;\n', 'utf8');

// Add filler files to make vertical scrolling observable.
for (let i = 0; i < 120; i++) {
const fillerA = path.join(rootA, 'src', `filler-a-${String(i).padStart(3, '0')}.ts`);
const fillerB = path.join(rootB, 'src', `filler-b-${String(i).padStart(3, '0')}.ts`);
await fs.writeFile(fillerA, `export const a${i} = ${i};\n`, 'utf8');
await fs.writeFile(fillerB, `export const b${i} = ${i};\n`, 'utf8');
}

const sessionA = await createSession(page, `E2E Search Session A ${suffix}`, rootA);
const sessionB = await createSession(page, `E2E Search Session B ${suffix}`, rootB);

try {
// 1) First locate in session A.
await page.goto(`/chat/${sessionA}?file=${encodeURIComponent(fileA)}&seek=seek1`);
const panel = page.locator('div[style*="width: 280"]');
await expect(panel).toBeVisible({ timeout: 15_000 });
await expect(page.locator('#file-tree-highlight')).toContainText('target-a.ts', { timeout: 15_000 });

// 2) Re-seek same file in same session; should remain stable and highlighted.
await page.goto(`/chat/${sessionA}?file=${encodeURIComponent(fileA)}&seek=seek2`);
await expect(page.locator('#file-tree-highlight')).toContainText('target-a.ts', { timeout: 15_000 });
await expect(page).toHaveURL(new RegExp(`/chat/${sessionA}\\?`));
await expect(page).toHaveURL(/seek=seek2/);

// 3) Cross-session locate should still work after previous seeks.
await page.goto(`/chat/${sessionB}?file=${encodeURIComponent(fileB)}&seek=seek3`);
await expect(page.locator('#file-tree-highlight')).toContainText('target-b.ts', { timeout: 15_000 });
await expect(page).toHaveURL(new RegExp(`/chat/${sessionB}\\?`));
} finally {
await fs.rm(rootA, { recursive: true, force: true });
await fs.rm(rootB, { recursive: true, force: true });
}
});
});
99 changes: 99 additions & 0 deletions src/__tests__/e2e/global-search-modes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { test, expect, type Page } from '@playwright/test';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import Database from 'better-sqlite3';

function getDbPath() {
const dataDir = process.env.CLAUDE_GUI_DATA_DIR || path.join(os.homedir(), '.codepilot');
return path.join(dataDir, 'codepilot.db');
}

function addMessage(sessionId: string, role: 'user' | 'assistant', content: string) {
const db = new Database(getDbPath());
try {
const id = crypto.randomBytes(16).toString('hex');
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
db.prepare(
'INSERT INTO messages (id, session_id, role, content, created_at, token_usage) VALUES (?, ?, ?, ?, ?, ?)'
).run(id, sessionId, role, content, now, null);
db.prepare('UPDATE chat_sessions SET updated_at = ? WHERE id = ?').run(now, sessionId);
} finally {
db.close();
}
}

async function createSession(page: Page, title: string, workingDirectory: string) {
const res = await page.request.post('/api/chat/sessions', {
data: { title, working_directory: workingDirectory },
});
expect(res.ok()).toBeTruthy();
const data = await res.json();
return data.session.id as string;
}

test.describe('Global Search modes UX', () => {
test('supports all/session/message/file modes and keyboard open', async ({ page }) => {
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const rootA = path.join(os.tmpdir(), `codepilot-search-modes-a-${suffix}`);
const rootB = path.join(os.tmpdir(), `codepilot-search-modes-b-${suffix}`);
const fileNameA = `alpha-${suffix}.ts`;
const filePathA = path.join(rootA, 'src', fileNameA);
const sessionTitleA = `Search Session Alpha ${suffix}`;
const sessionTitleB = `Search Session Beta ${suffix}`;
const messageTokenA = `message-token-alpha-${suffix}`;
const messageTokenB = `message-token-beta-${suffix}`;

await fs.mkdir(path.dirname(filePathA), { recursive: true });
await fs.mkdir(rootB, { recursive: true });
await fs.writeFile(filePathA, 'export const alpha = true;\n', 'utf8');

const sessionA = await createSession(page, sessionTitleA, rootA);
const sessionB = await createSession(page, sessionTitleB, rootB);
addMessage(sessionA, 'user', `User says ${messageTokenA}`);
addMessage(sessionB, 'assistant', `Assistant says ${messageTokenB}`);

const searchInput = page.locator(
'input[data-slot="command-input"], input[placeholder*="Search"], input[placeholder*="搜索"]'
).first();

try {
await page.goto(`/chat/${sessionA}`);

// Open global search from the sidebar trigger (language-agnostic fallback).
await page.getByRole('button', { name: /(搜索会话|Search sessions|Search)/i }).first().click();
await expect(searchInput).toBeVisible({ timeout: 10_000 });

// Default all-mode can find sessions, messages and files.
await searchInput.fill(suffix);
await expect(page.getByText(sessionTitleA).first()).toBeVisible();
await expect(page.getByText(fileNameA).first()).toBeVisible();
await expect(page.getByText(messageTokenA).first()).toBeVisible();

// session: prefix narrows to session result.
await searchInput.fill(`session:${sessionTitleA}`);
await expect(page.getByText(sessionTitleA).first()).toBeVisible();
await expect(page.getByText(fileNameA)).toHaveCount(0);

// message: prefix narrows to message snippets and supports navigation to target session.
await searchInput.fill(`message:${messageTokenB}`);
await expect(page.getByText(messageTokenB)).toBeVisible({ timeout: 10_000 });
await page.getByText(messageTokenB).first().click();
await expect(page).toHaveURL(new RegExp(`/chat/${sessionB}\\?message=`), { timeout: 10_000 });

// Re-open and verify file: prefix still works in the same UX flow.
await page.getByRole('button', { name: /(搜索会话|Search sessions|Search)/i }).first().click();
await expect(searchInput).toBeVisible({ timeout: 10_000 });
await searchInput.fill(`file:${fileNameA}`);
await expect(page.getByText(fileNameA)).toBeVisible({ timeout: 10_000 });
await page.getByText(fileNameA).first().click();
await expect(page).toHaveURL(new RegExp(`/chat/${sessionA}\\?file=`), { timeout: 10_000 });
} finally {
await page.request.delete(`/api/chat/sessions/${sessionA}`, { timeout: 5_000 }).catch(() => {});
await page.request.delete(`/api/chat/sessions/${sessionB}`, { timeout: 5_000 }).catch(() => {});
await fs.rm(rootA, { recursive: true, force: true });
await fs.rm(rootB, { recursive: true, force: true });
}
});
});
30 changes: 22 additions & 8 deletions src/app/api/app/updates/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ import { selectRecommendedReleaseAsset, type ReleaseAsset } from "@/lib/update-r

const GITHUB_REPO = "op7418/CodePilot";

function noUpdatePayload(currentVersion: string, runtimeInfo: ReturnType<typeof getRuntimeArchitectureInfo>) {
return {
latestVersion: currentVersion,
currentVersion,
updateAvailable: false,
releaseName: "",
releaseNotes: "",
publishedAt: "",
releaseUrl: "",
downloadUrl: "",
downloadAssetName: "",
detectedPlatform: runtimeInfo.platform,
detectedArch: runtimeInfo.processArch,
hostArch: runtimeInfo.hostArch,
runningUnderRosetta: runtimeInfo.runningUnderRosetta,
};
}

function compareSemver(a: string, b: string): number {
const pa = a.replace(/^v/, "").split(".").map(Number);
const pb = b.replace(/^v/, "").split(".").map(Number);
Expand All @@ -28,10 +46,7 @@ export async function GET() {
);

if (!res.ok) {
return NextResponse.json(
{ error: "Failed to fetch release info" },
{ status: 502 }
);
return NextResponse.json(noUpdatePayload(currentVersion, runtimeInfo));
}

const release = await res.json();
Expand All @@ -58,9 +73,8 @@ export async function GET() {
runningUnderRosetta: runtimeInfo.runningUnderRosetta,
});
} catch {
return NextResponse.json(
{ error: "Failed to check for updates" },
{ status: 500 }
);
const currentVersion = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const runtimeInfo = getRuntimeArchitectureInfo();
return NextResponse.json(noUpdatePayload(currentVersion, runtimeInfo));
}
}
152 changes: 152 additions & 0 deletions src/app/api/search/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { NextRequest } from 'next/server';
import { getAllSessions, searchMessages } from '@/lib/db';
import { scanDirectory } from '@/lib/files';
import type { ChatSession, FileTreeNode } from '@/types';

const FILE_SCAN_DEPTH = 2;
const MAX_RESULTS_PER_TYPE = 10;

interface SearchResultSession {
type: 'session';
id: string;
title: string;
projectName: string;
updatedAt: string;
}

interface SearchResultMessage {
type: 'message';
sessionId: string;
sessionTitle: string;
messageId: string;
role: 'user' | 'assistant';
snippet: string;
createdAt: string;
contentType: 'user' | 'assistant' | 'tool';
}

interface SearchResultFile {
type: 'file';
sessionId: string;
sessionTitle: string;
path: string;
name: string;
nodeType: 'file' | 'directory';
}

export interface SearchResponse {
sessions: SearchResultSession[];
messages: SearchResultMessage[];
files: SearchResultFile[];
}

function parseQuery(raw: string): { scope: 'all' | 'sessions' | 'messages' | 'files'; query: string } {
const trimmed = raw.trim();
const lower = trimmed.toLowerCase();
if (lower.startsWith('session:') || lower.startsWith('sessions:')) {
const prefixLen = lower.startsWith('session:') ? 8 : 9;
return { scope: 'sessions', query: trimmed.slice(prefixLen).trim() };
}
if (lower.startsWith('message:') || lower.startsWith('messages:')) {
const prefixLen = lower.startsWith('message:') ? 8 : 9;
return { scope: 'messages', query: trimmed.slice(prefixLen).trim() };
}
if (lower.startsWith('file:') || lower.startsWith('files:')) {
const prefixLen = lower.startsWith('file:') ? 5 : 6;
return { scope: 'files', query: trimmed.slice(prefixLen).trim() };
}
return { scope: 'all', query: trimmed };
}

function filterSessions(sessions: ChatSession[], query: string): SearchResultSession[] {
const q = query.toLowerCase();
return sessions
.filter(
(s) =>
s.title.toLowerCase().includes(q) ||
s.project_name.toLowerCase().includes(q),
)
.slice(0, MAX_RESULTS_PER_TYPE)
.map((s) => ({
type: 'session' as const,
id: s.id,
title: s.title,
projectName: s.project_name,
updatedAt: s.updated_at,
}));
}

function collectNodes(
tree: FileTreeNode[],
sessionId: string,
sessionTitle: string,
query: string,
results: SearchResultFile[],
): void {
if (results.length >= MAX_RESULTS_PER_TYPE) return;
const q = query.toLowerCase();
for (const node of tree) {
if (results.length >= MAX_RESULTS_PER_TYPE) break;
if (node.name.toLowerCase().includes(q)) {
results.push({
type: 'file',
sessionId,
sessionTitle,
path: node.path,
name: node.name,
nodeType: node.type,
});
}
if (node.type === 'directory' && node.children) {
collectNodes(node.children, sessionId, sessionTitle, query, results);
}
}
}

export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const rawQuery = searchParams.get('q') || '';
const { scope, query } = parseQuery(rawQuery);

if (!query) {
return Response.json({ sessions: [], messages: [], files: [] });
}

const allSessions = getAllSessions();
const result: SearchResponse = { sessions: [], messages: [], files: [] };

if (scope === 'all' || scope === 'sessions') {
result.sessions = filterSessions(allSessions, query);
}

if (scope === 'all' || scope === 'messages') {
const messageRows = searchMessages(query, { limit: MAX_RESULTS_PER_TYPE });
result.messages = messageRows.map((r) => ({
type: 'message' as const,
sessionId: r.sessionId,
sessionTitle: r.sessionTitle,
messageId: r.messageId,
role: r.role,
snippet: r.snippet,
createdAt: r.createdAt,
contentType: r.contentType,
}));
}

if (scope === 'all' || scope === 'files') {
for (const session of allSessions) {
if (!session.working_directory) continue;
const tree = await scanDirectory(session.working_directory, FILE_SCAN_DEPTH);
collectNodes(tree, session.id, session.title, query, result.files);
if (result.files.length >= MAX_RESULTS_PER_TYPE) break;
}
}

return Response.json(result);
} catch (error) {
const message = error instanceof Error ? error.stack || error.message : String(error);
console.error('[GET /api/search] Error:', message);
return Response.json({ error: message }, { status: 500 });
}
}
Loading
Loading