Skip to content

Commit 7ea0204

Browse files
anbei.yuanCopilot
andcommitted
feat(prompts): add agent / repo / time-range filters
- Backend: PromptSearchQuery now accepts agent, repo (substring), since, until (RFC 3339). Filters applied per-session before walking prompts to skip detail fetches when possible. - Frontend: filter row above results with agent dropdown, repo text input, time-range select (24h/7d/30d), Clear button. - i18n: en + zh strings for all filter UI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 399d0ca commit 7ea0204

4 files changed

Lines changed: 146 additions & 12 deletions

File tree

crates/pawscope-server/src/api.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,16 @@ pub struct PromptSearchQuery {
389389
pub q: Option<String>,
390390
#[serde(default)]
391391
pub limit: Option<usize>,
392+
#[serde(default)]
393+
pub agent: Option<String>,
394+
#[serde(default)]
395+
pub repo: Option<String>,
396+
/// Lower bound on prompt timestamp (RFC3339).
397+
#[serde(default)]
398+
pub since: Option<String>,
399+
/// Upper bound on prompt timestamp (RFC3339).
400+
#[serde(default)]
401+
pub until: Option<String>,
392402
}
393403

394404
#[derive(Debug, Serialize)]
@@ -415,6 +425,18 @@ pub async fn prompts_search(
415425
}
416426
let limit = p.limit.unwrap_or(50).min(200);
417427
let needle = q.to_lowercase();
428+
let agent_filter = p.agent.as_deref().map(str::to_lowercase);
429+
let repo_filter = p.repo.as_deref().map(|s| s.to_lowercase());
430+
let since = p
431+
.since
432+
.as_deref()
433+
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
434+
.map(|dt| dt.with_timezone(&chrono::Utc));
435+
let until = p
436+
.until
437+
.as_deref()
438+
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
439+
.map(|dt| dt.with_timezone(&chrono::Utc));
418440

419441
let sessions = match s.adapter.list_sessions().await {
420442
Ok(v) => v,
@@ -423,11 +445,38 @@ pub async fn prompts_search(
423445

424446
let mut hits: Vec<PromptHit> = Vec::new();
425447
for sess in &sessions {
448+
if let Some(af) = &agent_filter {
449+
let ak = serde_json::to_value(sess.agent)
450+
.ok()
451+
.and_then(|v| v.as_str().map(str::to_string))
452+
.unwrap_or_default();
453+
if &ak != af {
454+
continue;
455+
}
456+
}
457+
if let Some(rf) = &repo_filter {
458+
let r = sess.repo.as_deref().unwrap_or("").to_lowercase();
459+
if !r.contains(rf) {
460+
continue;
461+
}
462+
}
426463
let detail = match s.adapter.get_detail(&sess.id).await {
427464
Ok(d) => d,
428465
Err(_) => continue,
429466
};
430467
for prompt in &detail.prompts {
468+
if let Some(t) = prompt.timestamp {
469+
if let Some(s) = since {
470+
if t < s {
471+
continue;
472+
}
473+
}
474+
if let Some(u) = until {
475+
if t > u {
476+
continue;
477+
}
478+
}
479+
}
431480
let hay_snip = prompt.snippet.to_lowercase();
432481
let hay_text = prompt.text.to_lowercase();
433482
if !needle.is_empty() && !hay_snip.contains(&needle) && !hay_text.contains(&needle) {

web/src/api.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,24 @@ export interface PromptHit {
126126
timestamp: string | null;
127127
snippet: string;
128128
}
129-
export async function searchPrompts(q: string, limit = 50): Promise<PromptHit[]> {
129+
export interface PromptSearchFilters {
130+
agent?: string;
131+
repo?: string;
132+
since?: string;
133+
until?: string;
134+
}
135+
export async function searchPrompts(
136+
q: string,
137+
limit = 50,
138+
filters: PromptSearchFilters = {},
139+
): Promise<PromptHit[]> {
130140
const params = new URLSearchParams();
131141
if (q) params.set('q', q);
132142
params.set('limit', String(limit));
143+
if (filters.agent) params.set('agent', filters.agent);
144+
if (filters.repo) params.set('repo', filters.repo);
145+
if (filters.since) params.set('since', filters.since);
146+
if (filters.until) params.set('until', filters.until);
133147
const r = await fetch(`/api/prompts/search?${params}`);
134148
if (!r.ok) throw new Error(`prompts search ${r.status}`);
135149
return r.json();

web/src/components/PromptsPanel.tsx

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useMemo, useRef, useState } from 'react';
2-
import { searchPrompts, type PromptHit } from '../api';
2+
import { searchPrompts, type PromptHit, type PromptSearchFilters } from '../api';
33
import { useT } from '../i18n';
44

55
const AGENT_BADGE: Record<string, string> = {
@@ -8,6 +8,8 @@ const AGENT_BADGE: Record<string, string> = {
88
codex: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30',
99
};
1010

11+
const RANGE_HOURS: Record<string, number> = { '24h': 24, '7d': 24 * 7, '30d': 24 * 30 };
12+
1113
function relTime(ts: string | null): string {
1214
if (!ts) return '';
1315
const dt = new Date(ts).getTime();
@@ -45,29 +47,48 @@ interface Props {
4547
export function PromptsPanel({ onOpenSession }: Props) {
4648
const { t } = useT();
4749
const [q, setQ] = useState('');
50+
const [agent, setAgent] = useState<string>('');
51+
const [repo, setRepo] = useState<string>('');
52+
const [range, setRange] = useState<string>('');
4853
const [hits, setHits] = useState<PromptHit[]>([]);
4954
const [loading, setLoading] = useState(false);
5055
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
5156

57+
const filters: PromptSearchFilters = useMemo(() => {
58+
const f: PromptSearchFilters = {};
59+
if (agent) f.agent = agent;
60+
if (repo.trim()) f.repo = repo.trim();
61+
if (range && RANGE_HOURS[range]) {
62+
const since = new Date(Date.now() - RANGE_HOURS[range] * 3600_000);
63+
f.since = since.toISOString();
64+
}
65+
return f;
66+
}, [agent, repo, range]);
67+
5268
useEffect(() => {
5369
if (debounceRef.current) clearTimeout(debounceRef.current);
5470
debounceRef.current = setTimeout(() => {
5571
setLoading(true);
56-
searchPrompts(q.trim(), 100)
72+
searchPrompts(q.trim(), 100, filters)
5773
.then(setHits)
5874
.catch(() => setHits([]))
5975
.finally(() => setLoading(false));
6076
}, 250);
6177
return () => {
6278
if (debounceRef.current) clearTimeout(debounceRef.current);
6379
};
64-
}, [q]);
80+
}, [q, filters]);
81+
82+
const filterActive = !!(agent || repo.trim() || range);
6583

6684
const header = useMemo(() => {
6785
if (loading) return t('prompts.loading');
68-
if (q.trim()) return `${hits.length} ${t('prompts.results')}`;
86+
if (q.trim() || filterActive) return `${hits.length} ${t('prompts.results')}`;
6987
return t('prompts.recent');
70-
}, [loading, hits.length, q, t]);
88+
}, [loading, hits.length, q, filterActive, t]);
89+
90+
const selectClass =
91+
'bg-slate-900 border border-slate-700 rounded px-2 py-1 text-xs text-slate-200 focus:outline-none focus:border-emerald-500/60';
7192

7293
return (
7394
<div className="flex-1 flex flex-col overflow-hidden bg-slate-950">
@@ -95,7 +116,49 @@ export function PromptsPanel({ onOpenSession }: Props) {
95116
className="w-full bg-slate-900 border border-slate-700 rounded-md pl-9 pr-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-emerald-500/60 focus:ring-1 focus:ring-emerald-500/30"
96117
/>
97118
</div>
98-
<div className="mt-2 text-[11px] uppercase tracking-wider text-slate-500">{header}</div>
119+
<div className="mt-3 flex flex-wrap items-center gap-2">
120+
<label className="flex items-center gap-1.5 text-[11px] text-slate-400">
121+
<span className="uppercase tracking-wider">{t('prompts.filter.agent')}</span>
122+
<select className={selectClass} value={agent} onChange={(e) => setAgent(e.target.value)}>
123+
<option value="">{t('prompts.filter.agent.all')}</option>
124+
<option value="copilot">copilot</option>
125+
<option value="claude">claude</option>
126+
<option value="codex">codex</option>
127+
</select>
128+
</label>
129+
<label className="flex items-center gap-1.5 text-[11px] text-slate-400">
130+
<span className="uppercase tracking-wider">{t('prompts.filter.repo')}</span>
131+
<input
132+
type="text"
133+
value={repo}
134+
onChange={(e) => setRepo(e.target.value)}
135+
placeholder="owner/name"
136+
className={`${selectClass} w-44`}
137+
/>
138+
</label>
139+
<label className="flex items-center gap-1.5 text-[11px] text-slate-400">
140+
<span className="uppercase tracking-wider">{t('prompts.filter.range')}</span>
141+
<select className={selectClass} value={range} onChange={(e) => setRange(e.target.value)}>
142+
<option value="">{t('prompts.filter.range.all')}</option>
143+
<option value="24h">{t('prompts.filter.range.24h')}</option>
144+
<option value="7d">{t('prompts.filter.range.7d')}</option>
145+
<option value="30d">{t('prompts.filter.range.30d')}</option>
146+
</select>
147+
</label>
148+
{filterActive && (
149+
<button
150+
onClick={() => {
151+
setAgent('');
152+
setRepo('');
153+
setRange('');
154+
}}
155+
className="text-[11px] text-slate-400 hover:text-slate-200 underline underline-offset-2"
156+
>
157+
{t('prompts.filter.clear')}
158+
</button>
159+
)}
160+
<span className="ml-auto text-[11px] uppercase tracking-wider text-slate-500">{header}</span>
161+
</div>
99162
</div>
100163

101164
<div className="flex-1 overflow-y-auto">
@@ -120,9 +183,7 @@ export function PromptsPanel({ onOpenSession }: Props) {
120183
{h.repo && (
121184
<span className="text-[11px] text-slate-400 truncate max-w-xs">{h.repo}</span>
122185
)}
123-
{h.branch && (
124-
<span className="text-[11px] text-slate-500">· {h.branch}</span>
125-
)}
186+
{h.branch && <span className="text-[11px] text-slate-500">· {h.branch}</span>}
126187
<span className="ml-auto text-[11px] text-slate-500 font-mono">
127188
{relTime(h.timestamp)}
128189
</span>

web/src/i18n.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,21 @@ const dict: Record<string, { en: string; zh: string }> = {
1515

1616
// Prompts search
1717
'prompts.title': { en: 'Search prompts', zh: '搜索用户提示' },
18-
'prompts.placeholder': { en: 'Type to search across all sessions…', zh: '输入关键字跨所有会话搜索…' },
18+
'prompts.placeholder': { en: 'Type to search across all sessions…', zh: '输入关键字,跨所有会话搜索…' },
1919
'prompts.empty': { en: 'No matches.', zh: '没有匹配结果。' },
2020
'prompts.loading': { en: 'Searching…', zh: '搜索中…' },
2121
'prompts.results': { en: 'results', zh: '条结果' },
22-
'prompts.recent': { en: 'Recent prompts (no filter)', zh: '最近的用户提示(无过滤)' },
22+
'prompts.recent': { en: 'Recent prompts (no filter)', zh: '最近的用户提示(无过滤)' },
23+
'prompts.filters': { en: 'Filters', zh: '过滤器' },
24+
'prompts.filter.agent': { en: 'Agent', zh: 'Agent' },
25+
'prompts.filter.agent.all': { en: 'All', zh: '全部' },
26+
'prompts.filter.repo': { en: 'Repo contains', zh: '仓库包含' },
27+
'prompts.filter.range': { en: 'Range', zh: '时间' },
28+
'prompts.filter.range.all': { en: 'All time', zh: '全部时间' },
29+
'prompts.filter.range.24h': { en: 'Last 24h', zh: '近 24 小时' },
30+
'prompts.filter.range.7d': { en: 'Last 7d', zh: '近 7 天' },
31+
'prompts.filter.range.30d': { en: 'Last 30d', zh: '近 30 天' },
32+
'prompts.filter.clear': { en: 'Clear', zh: '清除' },
2333

2434
// Tool timeline
2535
'sec.tool_timeline': { en: 'Tool call timeline', zh: '工具调用时间轴' },

0 commit comments

Comments
 (0)