Skip to content

Commit 3fef069

Browse files
grichaclaude
andcommitted
Add server-side session search and UI improvements
- Implement full-text search using ripgrep across all session files - Move search bar into sessions navbar (no extra vertical space) - Add optimistic updates for instant deletion feedback - Fix e2e test for stop button (now in settings tab) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8161c9d commit 3fef069

File tree

5 files changed

+272
-68
lines changed

5 files changed

+272
-68
lines changed

src/agent/router.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
getSessionMessages,
2323
findSessionMessages,
2424
deleteSession as deleteSessionFromProvider,
25+
searchSessions as searchSessionsInContainer,
2526
} from '../sessions/agents';
2627
import type { SessionsCacheManager } from '../sessions/cache';
2728
import type { ModelCacheManager } from '../models/cache';
@@ -769,6 +770,120 @@ export function createRouter(ctx: RouterContext) {
769770
return { success: true };
770771
});
771772

773+
const searchSessions = os
774+
.input(
775+
z.object({
776+
workspaceName: z.string(),
777+
query: z.string().min(1).max(500),
778+
})
779+
)
780+
.handler(async ({ input }) => {
781+
const isHost = input.workspaceName === HOST_WORKSPACE_NAME;
782+
783+
if (isHost) {
784+
const config = ctx.config.get();
785+
if (!config.allowHostAccess) {
786+
throw new ORPCError('PRECONDITION_FAILED', { message: 'Host access is disabled' });
787+
}
788+
789+
const results = await searchHostSessions(input.query);
790+
return { results };
791+
}
792+
793+
const workspace = await ctx.workspaces.get(input.workspaceName);
794+
if (!workspace) {
795+
throw new ORPCError('NOT_FOUND', { message: 'Workspace not found' });
796+
}
797+
if (workspace.status !== 'running') {
798+
throw new ORPCError('PRECONDITION_FAILED', { message: 'Workspace is not running' });
799+
}
800+
801+
const containerName = `workspace-${input.workspaceName}`;
802+
const results = await searchSessionsInContainer(containerName, input.query, execInContainer);
803+
804+
return { results };
805+
});
806+
807+
async function searchHostSessions(query: string): Promise<
808+
Array<{
809+
sessionId: string;
810+
agentType: 'claude-code' | 'opencode' | 'codex';
811+
matchCount: number;
812+
}>
813+
> {
814+
const homeDir = os_module.homedir();
815+
const safeQuery = query.replace(/['"\\]/g, '\\$&');
816+
const searchPaths = [
817+
path.join(homeDir, '.claude', 'projects'),
818+
path.join(homeDir, '.local', 'share', 'opencode', 'storage'),
819+
path.join(homeDir, '.codex', 'sessions'),
820+
].filter((p) => {
821+
try {
822+
require('fs').accessSync(p);
823+
return true;
824+
} catch {
825+
return false;
826+
}
827+
});
828+
829+
if (searchPaths.length === 0) {
830+
return [];
831+
}
832+
833+
const { execSync } = await import('child_process');
834+
try {
835+
const output = execSync(
836+
`rg -l -i --no-messages "${safeQuery}" ${searchPaths.join(' ')} 2>/dev/null | head -100`,
837+
{
838+
encoding: 'utf-8',
839+
timeout: 30000,
840+
}
841+
);
842+
843+
const files = output.trim().split('\n').filter(Boolean);
844+
const results: Array<{
845+
sessionId: string;
846+
agentType: 'claude-code' | 'opencode' | 'codex';
847+
matchCount: number;
848+
}> = [];
849+
850+
for (const file of files) {
851+
let sessionId: string | null = null;
852+
let agentType: 'claude-code' | 'opencode' | 'codex' | null = null;
853+
854+
if (file.includes('/.claude/projects/')) {
855+
const match = file.match(/\/([^/]+)\.jsonl$/);
856+
if (match && !match[1].startsWith('agent-')) {
857+
sessionId = match[1];
858+
agentType = 'claude-code';
859+
}
860+
} else if (file.includes('/.local/share/opencode/storage/')) {
861+
if (file.includes('/session/') && file.endsWith('.json')) {
862+
const match = file.match(/\/(ses_[^/]+)\.json$/);
863+
if (match) {
864+
sessionId = match[1];
865+
agentType = 'opencode';
866+
}
867+
}
868+
} else if (file.includes('/.codex/sessions/')) {
869+
const match = file.match(/\/([^/]+)\.jsonl$/);
870+
if (match) {
871+
sessionId = match[1];
872+
agentType = 'codex';
873+
}
874+
}
875+
876+
if (sessionId && agentType) {
877+
results.push({ sessionId, agentType, matchCount: 1 });
878+
}
879+
}
880+
881+
return results;
882+
} catch {
883+
return [];
884+
}
885+
}
886+
772887
async function deleteHostSession(
773888
sessionId: string,
774889
agentType: 'claude-code' | 'opencode' | 'codex'
@@ -941,6 +1056,7 @@ export function createRouter(ctx: RouterContext) {
9411056
getRecent: getRecentSessions,
9421057
recordAccess: recordSessionAccess,
9431058
delete: deleteSession,
1059+
search: searchSessions,
9441060
},
9451061
models: {
9461062
list: listModels,

src/sessions/agents/index.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,77 @@ export async function deleteSession(
7878
}
7979
return provider.deleteSession(containerName, sessionId, exec);
8080
}
81+
82+
export interface SearchResult {
83+
sessionId: string;
84+
agentType: AgentType;
85+
filePath: string;
86+
matchCount: number;
87+
}
88+
89+
export async function searchSessions(
90+
containerName: string,
91+
query: string,
92+
exec: ExecInContainer
93+
): Promise<SearchResult[]> {
94+
const safeQuery = query.replace(/['"\\]/g, '\\$&');
95+
96+
const searchPaths = [
97+
'/home/workspace/.claude/projects',
98+
'/home/workspace/.local/share/opencode/storage',
99+
'/home/workspace/.codex/sessions',
100+
];
101+
102+
const rgCommand = `rg -l -i --no-messages "${safeQuery}" ${searchPaths.join(' ')} 2>/dev/null | head -100`;
103+
104+
const result = await exec(containerName, ['bash', '-c', rgCommand], {
105+
user: 'workspace',
106+
});
107+
108+
if (result.exitCode !== 0 || !result.stdout.trim()) {
109+
return [];
110+
}
111+
112+
const files = result.stdout.trim().split('\n').filter(Boolean);
113+
const results: SearchResult[] = [];
114+
115+
for (const file of files) {
116+
let sessionId: string | null = null;
117+
let agentType: AgentType | null = null;
118+
119+
if (file.includes('/.claude/projects/')) {
120+
const match = file.match(/\/([^/]+)\.jsonl$/);
121+
if (match && !match[1].startsWith('agent-')) {
122+
sessionId = match[1];
123+
agentType = 'claude-code';
124+
}
125+
} else if (file.includes('/.local/share/opencode/storage/')) {
126+
if (file.includes('/session/') && file.endsWith('.json')) {
127+
const match = file.match(/\/(ses_[^/]+)\.json$/);
128+
if (match) {
129+
sessionId = match[1];
130+
agentType = 'opencode';
131+
}
132+
} else if (file.includes('/part/') || file.includes('/message/')) {
133+
continue;
134+
}
135+
} else if (file.includes('/.codex/sessions/')) {
136+
const match = file.match(/\/([^/]+)\.jsonl$/);
137+
if (match) {
138+
sessionId = match[1];
139+
agentType = 'codex';
140+
}
141+
}
142+
143+
if (sessionId && agentType) {
144+
results.push({
145+
sessionId,
146+
agentType,
147+
filePath: file,
148+
matchCount: 1,
149+
});
150+
}
151+
}
152+
153+
return results;
154+
}

test/web/workspace.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,10 @@ test.describe('Web UI - Workspace Operations', () => {
6363
await agent.api.createWorkspace({ name: workspaceName });
6464

6565
try {
66-
await page.goto(`http://127.0.0.1:${agent.port}/workspaces/${workspaceName}`);
66+
await page.goto(`http://127.0.0.1:${agent.port}/workspaces/${workspaceName}?tab=settings`);
6767
await expect(page.getByText(workspaceName).first()).toBeVisible({ timeout: 30000 });
6868

69-
const stopButton = page.getByRole('button', { name: /stop/i });
69+
const stopButton = page.getByRole('button', { name: /^stop$/i });
7070
await stopButton.click();
7171

7272
await expect(page.getByText('stopped').first()).toBeVisible({ timeout: 30000 });

web/src/lib/api.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ const client = createORPCClient<{
7373
getRecent: (input: { limit?: number }) => Promise<{ sessions: RecentSession[] }>
7474
recordAccess: (input: { workspaceName: string; sessionId: string; agentType: AgentType }) => Promise<{ success: boolean }>
7575
delete: (input: { workspaceName: string; sessionId: string; agentType: AgentType }) => Promise<{ success: boolean }>
76+
search: (input: { workspaceName: string; query: string }) => Promise<{ results: Array<{ sessionId: string; agentType: AgentType; matchCount: number }> }>
7677
}
7778
models: {
7879
list: (input: { agentType: 'claude-code' | 'opencode'; workspaceName?: string }) => Promise<{ models: ModelInfo[] }>
@@ -128,6 +129,8 @@ export const api = {
128129
client.sessions.clearName({ workspaceName, sessionId }),
129130
deleteSession: (workspaceName: string, sessionId: string, agentType: AgentType) =>
130131
client.sessions.delete({ workspaceName, sessionId, agentType }),
132+
searchSessions: (workspaceName: string, query: string) =>
133+
client.sessions.search({ workspaceName, query }),
131134
getInfo: () => client.info(),
132135
getCredentials: () => client.config.credentials.get(),
133136
updateCredentials: (data: Credentials) => client.config.credentials.update(data),

0 commit comments

Comments
 (0)