Skip to content

Commit 9a63bf8

Browse files
anbei.yuanCopilot
andcommitted
feat: star + tag sessions
- Backend - LabelStore (Arc<RwLock<HashMap>>) persisted to ~/.pawscope/labels.json on every change - GET /api/labels returns the full map - POST /api/labels/{id} body { starred, tags } Normalizes tags: trims, strips empties, caps at 16 tags, each tag <= 32 chars. Empty labels are removed from store. - Frontend - SessionList: ☆/★ toggle on each row, tag chips inline, new ★-only filter, clickable tag pill row - SessionDetail: ★ button in header, tag editor below header (chips + Enter-to-add) - Labels loaded once at startup; in-memory map shared across all panels; updates fire async POST. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e7ea6ce commit 9a63bf8

9 files changed

Lines changed: 268 additions & 10 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/pawscope-server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ mime_guess = "2"
1919
futures = "0.3"
2020
tokio-stream = { version = "0.1", features = ["sync"] }
2121
chrono = { version = "0.4", features = ["serde"] }
22+
dirs = "5"
2223

2324
[dev-dependencies]
2425
reqwest = { version = "0.12", features = ["json"] }

crates/pawscope-server/src/api.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,3 +669,28 @@ pub async fn tools_bucket(
669669

670670
Json(hits).into_response()
671671
}
672+
673+
pub async fn list_labels(State(s): State<AppState>) -> impl IntoResponse {
674+
Json(s.labels.snapshot().await).into_response()
675+
}
676+
677+
pub async fn set_label(
678+
Path(id): Path<String>,
679+
State(s): State<AppState>,
680+
Json(label): Json<crate::labels::Label>,
681+
) -> impl IntoResponse {
682+
let normalized = crate::labels::Label {
683+
starred: label.starred,
684+
tags: label
685+
.tags
686+
.into_iter()
687+
.map(|t| t.trim().to_string())
688+
.filter(|t| !t.is_empty() && t.len() <= 32)
689+
.take(16)
690+
.collect(),
691+
};
692+
match s.labels.set(&id, normalized.clone()).await {
693+
Ok(()) => Json(normalized).into_response(),
694+
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
695+
}
696+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
use serde::{Deserialize, Serialize};
2+
use std::collections::HashMap;
3+
use std::path::PathBuf;
4+
use std::sync::Arc;
5+
use tokio::sync::RwLock;
6+
7+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
8+
pub struct Label {
9+
#[serde(default)]
10+
pub starred: bool,
11+
#[serde(default)]
12+
pub tags: Vec<String>,
13+
}
14+
15+
impl Label {
16+
pub fn is_empty(&self) -> bool {
17+
!self.starred && self.tags.is_empty()
18+
}
19+
}
20+
21+
#[derive(Clone)]
22+
pub struct LabelStore {
23+
path: PathBuf,
24+
inner: Arc<RwLock<HashMap<String, Label>>>,
25+
}
26+
27+
fn default_path() -> PathBuf {
28+
dirs::home_dir()
29+
.unwrap_or_else(|| PathBuf::from("."))
30+
.join(".pawscope")
31+
.join("labels.json")
32+
}
33+
34+
impl LabelStore {
35+
pub async fn load() -> Self {
36+
Self::load_from(default_path()).await
37+
}
38+
39+
pub async fn load_from(path: PathBuf) -> Self {
40+
let map = tokio::fs::read_to_string(&path)
41+
.await
42+
.ok()
43+
.and_then(|s| serde_json::from_str::<HashMap<String, Label>>(&s).ok())
44+
.unwrap_or_default();
45+
Self {
46+
path,
47+
inner: Arc::new(RwLock::new(map)),
48+
}
49+
}
50+
51+
pub async fn snapshot(&self) -> HashMap<String, Label> {
52+
self.inner.read().await.clone()
53+
}
54+
55+
pub async fn get(&self, id: &str) -> Label {
56+
self.inner.read().await.get(id).cloned().unwrap_or_default()
57+
}
58+
59+
pub async fn set(&self, id: &str, label: Label) -> std::io::Result<()> {
60+
{
61+
let mut g = self.inner.write().await;
62+
if label.is_empty() {
63+
g.remove(id);
64+
} else {
65+
g.insert(id.to_string(), label);
66+
}
67+
}
68+
self.persist().await
69+
}
70+
71+
async fn persist(&self) -> std::io::Result<()> {
72+
if let Some(parent) = self.path.parent() {
73+
tokio::fs::create_dir_all(parent).await?;
74+
}
75+
let snap = self.inner.read().await;
76+
let body = serde_json::to_string_pretty(&*snap).map_err(std::io::Error::other)?;
77+
tokio::fs::write(&self.path, body).await
78+
}
79+
}

crates/pawscope-server/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use tokio::sync::broadcast;
99
pub mod api;
1010
pub mod assets;
1111
pub mod cache;
12+
pub mod labels;
1213
pub mod multi;
1314
pub mod skills;
1415
pub mod sse;
@@ -21,14 +22,17 @@ pub struct AppState {
2122
pub adapter: Arc<dyn AgentAdapter>,
2223
pub events: broadcast::Sender<pawscope_core::SessionEvent>,
2324
pub detail_cache: cache::DetailCache,
25+
pub labels: labels::LabelStore,
2426
}
2527

2628
pub fn build_app(adapter: Arc<dyn AgentAdapter>) -> (Router, AppState) {
2729
let (tx, _) = broadcast::channel(256);
30+
let labels = futures::executor::block_on(labels::LabelStore::load());
2831
let state = AppState {
2932
adapter,
3033
events: tx,
3134
detail_cache: cache::DetailCache::new(),
35+
labels,
3236
};
3337
let router = Router::new()
3438
.route("/api/sessions", get(api::list_sessions))
@@ -40,6 +44,8 @@ pub fn build_app(adapter: Arc<dyn AgentAdapter>) -> (Router, AppState) {
4044
.route("/api/prompts/search", get(api::prompts_search))
4145
.route("/api/tools/trend", get(api::tools_trend))
4246
.route("/api/tools/bucket", get(api::tools_bucket))
47+
.route("/api/labels", get(api::list_labels))
48+
.route("/api/labels/{id}", post(api::set_label))
4349
.route("/api/skills", get(skills::list_skills))
4450
.route("/api/skills/content", get(skills::skill_content))
4551
.route("/api/skills/usage", get(skills::skill_usage))

web/src/App.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect, useState } from 'react';
22
import './styles.css';
3-
import { fetchSessions, fetchDetail, connectWs } from './api';
3+
import { fetchSessions, fetchDetail, connectWs, fetchLabels, setLabel as apiSetLabel, type LabelMap } from './api';
44
import { SessionList } from './components/SessionList';
55
import { SessionDetail } from './components/SessionDetail';
66
import { OverviewPanel } from './components/OverviewPanel';
@@ -23,6 +23,20 @@ export default function App() {
2323
const [realmPage, setRealmPage] = useState<string | null>(null);
2424
const [pendingSkill, setPendingSkill] = useState<{ name: string; n: number } | null>(null);
2525
const [pendingCategory, setPendingCategory] = useState<{ name: string; n: number } | null>(null);
26+
const [labels, setLabels] = useState<LabelMap>({});
27+
28+
useEffect(() => {
29+
fetchLabels().then(setLabels).catch(() => setLabels({}));
30+
}, []);
31+
32+
const updateLabel = (id: string, label: { starred: boolean; tags: string[] }) => {
33+
setLabels((prev) => ({ ...prev, [id]: label }));
34+
apiSetLabel(id, label).catch(() => {});
35+
};
36+
const toggleStar = (id: string) => {
37+
const cur = labels[id] ?? { starred: false, tags: [] };
38+
updateLabel(id, { ...cur, starred: !cur.starred });
39+
};
2640

2741
useEffect(() => {
2842
fetchSessions().then(setSessions);
@@ -120,6 +134,8 @@ export default function App() {
120134
selected={selected}
121135
realmFilter={realmFilter}
122136
onClearRealmFilter={() => setRealmFilter(null)}
137+
labels={labels}
138+
onToggleStar={toggleStar}
123139
/>
124140
</div>
125141
{view === 'overview' ? (
@@ -165,6 +181,8 @@ export default function App() {
165181
setPendingSkill(p => ({ name, n: (p?.n ?? 0) + 1 }));
166182
setView('skills');
167183
}}
184+
label={selected ? labels[selected] : undefined}
185+
onSetLabel={selected ? (lbl) => updateLabel(selected, lbl) : undefined}
168186
/>
169187
)}
170188
</div>

web/src/api.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,24 @@ export async function searchPrompts(
185185
if (!r.ok) throw new Error(`prompts search ${r.status}`);
186186
return r.json();
187187
}
188+
189+
export interface Label {
190+
starred: boolean;
191+
tags: string[];
192+
}
193+
export type LabelMap = Record<string, Label>;
194+
195+
export async function fetchLabels(): Promise<LabelMap> {
196+
const r = await fetch('/api/labels');
197+
if (!r.ok) return {};
198+
return r.json();
199+
}
200+
export async function setLabel(id: string, label: Label): Promise<Label> {
201+
const r = await fetch(`/api/labels/${encodeURIComponent(id)}`, {
202+
method: 'POST',
203+
headers: { 'content-type': 'application/json' },
204+
body: JSON.stringify(label),
205+
});
206+
if (!r.ok) throw new Error(`set label ${r.status}`);
207+
return r.json();
208+
}

web/src/components/SessionDetail.tsx

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,13 @@ type Detail = {
3636
tool_calls?: { name: string; timestamp: string }[];
3737
};
3838

39-
type Props = { meta: Meta | undefined; detail: Detail | null; onOpenSkill?: (name: string) => void };
39+
type Props = {
40+
meta: Meta | undefined;
41+
detail: Detail | null;
42+
onOpenSkill?: (name: string) => void;
43+
label?: { starred: boolean; tags: string[] };
44+
onSetLabel?: (label: { starred: boolean; tags: string[] }) => void;
45+
};
4046

4147
function timeAgo(iso?: string | null): string {
4248
if (!iso) return '—';
@@ -149,7 +155,7 @@ function PromptRow({
149155
);
150156
}
151157

152-
export function SessionDetail({ meta, detail, onOpenSkill }: Props) {
158+
export function SessionDetail({ meta, detail, onOpenSkill, label, onSetLabel }: Props) {
153159
const { t, lang } = useT();
154160
const tools = useMemo(() => {
155161
if (!detail?.tools_used) return [];
@@ -191,6 +197,15 @@ export function SessionDetail({ meta, detail, onOpenSkill }: Props) {
191197
{meta.pid && (
192198
<span className="text-[11px] text-slate-500 font-mono">pid {meta.pid}</span>
193199
)}
200+
{onSetLabel && (
201+
<button
202+
onClick={() => onSetLabel({ starred: !(label?.starred ?? false), tags: label?.tags ?? [] })}
203+
title={label?.starred ? 'Unstar' : 'Star'}
204+
className={`ml-1 text-base leading-none ${label?.starred ? 'text-amber-300' : 'text-slate-600 hover:text-slate-400'}`}
205+
>
206+
{label?.starred ? '★' : '☆'}
207+
</button>
208+
)}
194209
</div>
195210
<h1 className="text-2xl font-semibold text-slate-100 truncate">
196211
{meta.summary || <span className="text-slate-500 italic">{t('misc.no_summary')}</span>}
@@ -228,6 +243,35 @@ export function SessionDetail({ meta, detail, onOpenSkill }: Props) {
228243
</dl>
229244
</header>
230245

246+
{onSetLabel && (
247+
<div className="px-8 py-2 border-b border-slate-800 bg-slate-900/20 flex items-center gap-2 flex-wrap">
248+
<span className="text-[10px] uppercase tracking-wider text-slate-500">tags</span>
249+
{(label?.tags ?? []).map((tg) => (
250+
<span key={tg} className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-violet-500/15 text-violet-200 text-[11px]">
251+
#{tg}
252+
<button
253+
onClick={() => onSetLabel({ starred: label?.starred ?? false, tags: (label?.tags ?? []).filter((x) => x !== tg) })}
254+
className="text-violet-400 hover:text-violet-100"
255+
>×</button>
256+
</span>
257+
))}
258+
<input
259+
type="text"
260+
placeholder="+ tag"
261+
onKeyDown={(e) => {
262+
if (e.key === 'Enter') {
263+
const v = (e.target as HTMLInputElement).value.trim();
264+
if (v && !(label?.tags ?? []).includes(v)) {
265+
onSetLabel({ starred: label?.starred ?? false, tags: [...(label?.tags ?? []), v] });
266+
}
267+
(e.target as HTMLInputElement).value = '';
268+
}
269+
}}
270+
className="px-2 py-0.5 text-[11px] bg-slate-900 border border-slate-800 rounded text-slate-200 placeholder:text-slate-600 focus:outline-none focus:border-slate-600 w-20"
271+
/>
272+
</div>
273+
)}
274+
231275
{!detail ? (
232276
<div className="p-8 text-sm text-slate-500">{t('detail.loading')}</div>
233277
) : (

0 commit comments

Comments
 (0)