claude-smart 0.2.31 → 0.2.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +2 -2
  2. package/bin/claude-smart.js +172 -18
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/.codex-plugin/plugin.json +1 -1
  6. package/plugin/dashboard/app/api/config/route.ts +11 -2
  7. package/plugin/dashboard/app/api/health/route.ts +45 -6
  8. package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +15 -7
  9. package/plugin/dashboard/app/api/rules/applied/route.ts +27 -0
  10. package/plugin/dashboard/app/configure/env/page.tsx +36 -31
  11. package/plugin/dashboard/app/configure/layout.tsx +1 -1
  12. package/plugin/dashboard/app/configure/server/page.tsx +8 -14
  13. package/plugin/dashboard/app/dashboard/page.tsx +311 -115
  14. package/plugin/dashboard/app/globals.css +80 -66
  15. package/plugin/dashboard/app/layout.tsx +13 -10
  16. package/plugin/dashboard/app/preferences/[id]/page.tsx +21 -32
  17. package/plugin/dashboard/app/preferences/page.tsx +154 -54
  18. package/plugin/dashboard/app/preferences/project/[id]/page.tsx +1 -0
  19. package/plugin/dashboard/app/rules/[id]/page.tsx +51 -0
  20. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +4 -4
  21. package/plugin/dashboard/app/sessions/page.tsx +14 -10
  22. package/plugin/dashboard/app/skills/page.tsx +175 -56
  23. package/plugin/dashboard/app/skills/project/[id]/page.tsx +22 -38
  24. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +20 -37
  25. package/plugin/dashboard/components/common/delete-learning-danger-zone.tsx +166 -0
  26. package/plugin/dashboard/components/common/empty-state.tsx +4 -2
  27. package/plugin/dashboard/components/common/learnings-badge.tsx +1 -1
  28. package/plugin/dashboard/components/common/page-header.tsx +5 -3
  29. package/plugin/dashboard/components/common/page-tabs.tsx +4 -4
  30. package/plugin/dashboard/components/common/stat-card.tsx +9 -5
  31. package/plugin/dashboard/components/layout/sidebar.tsx +24 -9
  32. package/plugin/dashboard/components/layout/top-bar.tsx +37 -25
  33. package/plugin/dashboard/components/ui/input.tsx +1 -0
  34. package/plugin/dashboard/hooks/use-settings.tsx +30 -61
  35. package/plugin/dashboard/hooks/use-stall-state.ts +5 -9
  36. package/plugin/dashboard/lib/config-file.ts +2 -0
  37. package/plugin/dashboard/lib/reflexio-client.ts +23 -48
  38. package/plugin/dashboard/lib/session-reader.ts +222 -6
  39. package/plugin/dashboard/lib/types.ts +20 -1
  40. package/plugin/dashboard/package-lock.json +70 -95
  41. package/plugin/dashboard/package.json +5 -2
  42. package/plugin/pyproject.toml +1 -1
  43. package/plugin/scripts/_lib.sh +61 -0
  44. package/plugin/scripts/backend-service.sh +13 -2
  45. package/plugin/scripts/cli.sh +1 -0
  46. package/plugin/scripts/codex-hook.js +100 -3
  47. package/plugin/scripts/dashboard-service.sh +95 -19
  48. package/plugin/scripts/hook_entry.sh +13 -7
  49. package/plugin/scripts/smart-install.sh +18 -13
  50. package/plugin/src/claude_smart/cli.py +86 -20
  51. package/plugin/src/claude_smart/context_format.py +244 -6
  52. package/plugin/src/claude_smart/context_inject.py +8 -1
  53. package/plugin/src/claude_smart/cs_cite.py +186 -34
  54. package/plugin/src/claude_smart/env_config.py +102 -0
  55. package/plugin/src/claude_smart/events/stop.py +32 -6
  56. package/plugin/src/claude_smart/internal_call.py +30 -0
  57. package/plugin/src/claude_smart/publish.py +5 -0
  58. package/plugin/src/claude_smart/reflexio_adapter.py +102 -26
  59. package/plugin/uv.lock +1 -1
@@ -7,8 +7,8 @@
7
7
  @theme inline {
8
8
  --color-background: var(--background);
9
9
  --color-foreground: var(--foreground);
10
- --font-sans: var(--font-sans);
11
- --font-mono: var(--font-geist-mono);
10
+ --font-sans: var(--dashboard-font-sans);
11
+ --font-mono: var(--dashboard-font-mono);
12
12
  --color-sidebar-ring: var(--sidebar-ring);
13
13
  --color-sidebar-border: var(--sidebar-border);
14
14
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -48,72 +48,76 @@
48
48
  }
49
49
 
50
50
  :root {
51
- --background: oklch(1 0 0);
52
- --foreground: oklch(0.145 0 0);
53
- --card: oklch(1 0 0);
54
- --card-foreground: oklch(0.145 0 0);
55
- --popover: oklch(1 0 0);
56
- --popover-foreground: oklch(0.145 0 0);
57
- --primary: oklch(0.205 0 0);
58
- --primary-foreground: oklch(0.985 0 0);
59
- --secondary: oklch(0.97 0 0);
60
- --secondary-foreground: oklch(0.205 0 0);
61
- --muted: oklch(0.97 0 0);
62
- --muted-foreground: oklch(0.556 0 0);
63
- --accent: oklch(0.97 0 0);
64
- --accent-foreground: oklch(0.205 0 0);
65
- --destructive: oklch(0.577 0.245 27.325);
66
- --border: oklch(0.922 0 0);
67
- --input: oklch(0.922 0 0);
68
- --ring: oklch(0.708 0 0);
69
- --chart-1: oklch(0.809 0.105 251.813);
70
- --chart-2: oklch(0.623 0.214 259.815);
71
- --chart-3: oklch(0.546 0.245 262.881);
72
- --chart-4: oklch(0.488 0.243 264.376);
73
- --chart-5: oklch(0.424 0.199 265.638);
74
- --radius: 0.625rem;
75
- --sidebar: oklch(0.985 0 0);
76
- --sidebar-foreground: oklch(0.145 0 0);
77
- --sidebar-primary: oklch(0.205 0 0);
78
- --sidebar-primary-foreground: oklch(0.985 0 0);
79
- --sidebar-accent: oklch(0.97 0 0);
80
- --sidebar-accent-foreground: oklch(0.205 0 0);
81
- --sidebar-border: oklch(0.922 0 0);
82
- --sidebar-ring: oklch(0.708 0 0);
51
+ --dashboard-font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
52
+ "Segoe UI", sans-serif;
53
+ --dashboard-font-mono: "SFMono-Regular", "SF Mono", Consolas, "Liberation Mono",
54
+ Menlo, monospace;
55
+ --background: oklch(0.982 0.006 197);
56
+ --foreground: oklch(0.19 0.018 236);
57
+ --card: oklch(0.997 0.003 197);
58
+ --card-foreground: oklch(0.19 0.018 236);
59
+ --popover: oklch(0.997 0.003 197);
60
+ --popover-foreground: oklch(0.19 0.018 236);
61
+ --primary: oklch(0.48 0.142 202);
62
+ --primary-foreground: oklch(0.99 0.006 197);
63
+ --secondary: oklch(0.939 0.025 202);
64
+ --secondary-foreground: oklch(0.245 0.038 218);
65
+ --muted: oklch(0.949 0.011 218);
66
+ --muted-foreground: oklch(0.486 0.024 230);
67
+ --accent: oklch(0.928 0.054 185);
68
+ --accent-foreground: oklch(0.235 0.052 205);
69
+ --destructive: oklch(0.58 0.216 27);
70
+ --border: oklch(0.882 0.018 218);
71
+ --input: oklch(0.892 0.018 218);
72
+ --ring: oklch(0.65 0.104 192);
73
+ --chart-1: oklch(0.68 0.13 188);
74
+ --chart-2: oklch(0.62 0.15 154);
75
+ --chart-3: oklch(0.72 0.16 82);
76
+ --chart-4: oklch(0.64 0.16 24);
77
+ --chart-5: oklch(0.54 0.13 258);
78
+ --radius: 0.5rem;
79
+ --sidebar: oklch(0.964 0.013 213);
80
+ --sidebar-foreground: oklch(0.22 0.026 236);
81
+ --sidebar-primary: oklch(0.48 0.142 202);
82
+ --sidebar-primary-foreground: oklch(0.99 0.006 197);
83
+ --sidebar-accent: oklch(0.918 0.043 190);
84
+ --sidebar-accent-foreground: oklch(0.24 0.054 206);
85
+ --sidebar-border: oklch(0.862 0.02 218);
86
+ --sidebar-ring: oklch(0.65 0.104 192);
83
87
  }
84
88
 
85
89
  .dark {
86
- --background: oklch(0.145 0 0);
87
- --foreground: oklch(0.985 0 0);
88
- --card: oklch(0.205 0 0);
89
- --card-foreground: oklch(0.985 0 0);
90
- --popover: oklch(0.205 0 0);
91
- --popover-foreground: oklch(0.985 0 0);
92
- --primary: oklch(0.922 0 0);
93
- --primary-foreground: oklch(0.205 0 0);
94
- --secondary: oklch(0.269 0 0);
95
- --secondary-foreground: oklch(0.985 0 0);
96
- --muted: oklch(0.269 0 0);
97
- --muted-foreground: oklch(0.708 0 0);
98
- --accent: oklch(0.269 0 0);
99
- --accent-foreground: oklch(0.985 0 0);
100
- --destructive: oklch(0.704 0.191 22.216);
101
- --border: oklch(1 0 0 / 10%);
102
- --input: oklch(1 0 0 / 15%);
103
- --ring: oklch(0.556 0 0);
104
- --chart-1: oklch(0.809 0.105 251.813);
105
- --chart-2: oklch(0.623 0.214 259.815);
106
- --chart-3: oklch(0.546 0.245 262.881);
107
- --chart-4: oklch(0.488 0.243 264.376);
108
- --chart-5: oklch(0.424 0.199 265.638);
109
- --sidebar: oklch(0.205 0 0);
110
- --sidebar-foreground: oklch(0.985 0 0);
111
- --sidebar-primary: oklch(0.488 0.243 264.376);
112
- --sidebar-primary-foreground: oklch(0.985 0 0);
113
- --sidebar-accent: oklch(0.269 0 0);
114
- --sidebar-accent-foreground: oklch(0.985 0 0);
115
- --sidebar-border: oklch(1 0 0 / 10%);
116
- --sidebar-ring: oklch(0.556 0 0);
90
+ --background: oklch(0.154 0.022 236);
91
+ --foreground: oklch(0.938 0.012 202);
92
+ --card: oklch(0.205 0.027 236);
93
+ --card-foreground: oklch(0.938 0.012 202);
94
+ --popover: oklch(0.205 0.027 236);
95
+ --popover-foreground: oklch(0.938 0.012 202);
96
+ --primary: oklch(0.72 0.119 188);
97
+ --primary-foreground: oklch(0.154 0.022 236);
98
+ --secondary: oklch(0.265 0.029 235);
99
+ --secondary-foreground: oklch(0.914 0.014 202);
100
+ --muted: oklch(0.258 0.027 235);
101
+ --muted-foreground: oklch(0.69 0.026 219);
102
+ --accent: oklch(0.306 0.054 202);
103
+ --accent-foreground: oklch(0.9 0.05 183);
104
+ --destructive: oklch(0.66 0.21 24);
105
+ --border: oklch(0.332 0.031 236);
106
+ --input: oklch(0.354 0.034 236);
107
+ --ring: oklch(0.72 0.119 188);
108
+ --chart-1: oklch(0.72 0.119 188);
109
+ --chart-2: oklch(0.72 0.14 148);
110
+ --chart-3: oklch(0.78 0.15 82);
111
+ --chart-4: oklch(0.68 0.17 24);
112
+ --chart-5: oklch(0.69 0.12 258);
113
+ --sidebar: oklch(0.184 0.027 236);
114
+ --sidebar-foreground: oklch(0.916 0.014 202);
115
+ --sidebar-primary: oklch(0.72 0.119 188);
116
+ --sidebar-primary-foreground: oklch(0.154 0.022 236);
117
+ --sidebar-accent: oklch(0.29 0.051 202);
118
+ --sidebar-accent-foreground: oklch(0.9 0.05 183);
119
+ --sidebar-border: oklch(0.332 0.031 236);
120
+ --sidebar-ring: oklch(0.72 0.119 188);
117
121
  }
118
122
 
119
123
  @layer base {
@@ -122,8 +126,18 @@
122
126
  }
123
127
  body {
124
128
  @apply bg-background text-foreground;
129
+ background-image:
130
+ linear-gradient(to right, oklch(0.88 0.018 218 / 0.45) 1px, transparent 1px),
131
+ linear-gradient(to bottom, oklch(0.88 0.018 218 / 0.45) 1px, transparent 1px);
132
+ background-size: 32px 32px;
133
+ background-position: -1px -1px;
125
134
  }
126
135
  html {
127
136
  @apply font-sans;
128
137
  }
129
- }
138
+ .dark body {
139
+ background-image:
140
+ linear-gradient(to right, oklch(0.38 0.032 236 / 0.28) 1px, transparent 1px),
141
+ linear-gradient(to bottom, oklch(0.38 0.032 236 / 0.28) 1px, transparent 1px);
142
+ }
143
+ }
@@ -1,16 +1,10 @@
1
1
  import type { Metadata } from "next";
2
- import { Inter } from "next/font/google";
3
2
  import "./globals.css";
4
3
  import { Providers } from "./providers";
5
4
  import { Sidebar } from "@/components/layout/sidebar";
6
5
  import { TopBar } from "@/components/layout/top-bar";
7
6
  import { StallBanner } from "@/components/stall-banner";
8
7
 
9
- const inter = Inter({
10
- variable: "--font-sans",
11
- subsets: ["latin"],
12
- });
13
-
14
8
  export const metadata: Metadata = {
15
9
  title: "Claude-Smart Dashboard",
16
10
  description: "Manage sessions, preferences, skills, and configuration",
@@ -22,16 +16,25 @@ export default function RootLayout({
22
16
  children: React.ReactNode;
23
17
  }>) {
24
18
  return (
25
- <html lang="en" className={`${inter.variable} h-full`} suppressHydrationWarning>
26
- <body className="h-full flex flex-col antialiased font-sans">
19
+ <html
20
+ lang="en"
21
+ className="h-full"
22
+ suppressHydrationWarning
23
+ >
24
+ <body
25
+ className="h-full flex flex-col antialiased font-sans"
26
+ suppressHydrationWarning
27
+ >
27
28
  <Providers>
28
29
  <StallBanner />
29
30
  <TopBar />
30
31
  <div className="flex flex-1 min-h-0">
31
- <aside className="hidden lg:block w-60 border-r border-border shrink-0">
32
+ <aside className="hidden lg:block w-64 border-r border-sidebar-border bg-sidebar/95 shrink-0">
32
33
  <Sidebar />
33
34
  </aside>
34
- <main className="flex-1 min-w-0 flex flex-col">{children}</main>
35
+ <main className="flex-1 min-w-0 flex flex-col bg-background/88">
36
+ {children}
37
+ </main>
35
38
  </div>
36
39
  </Providers>
37
40
  </body>
@@ -5,7 +5,6 @@ import Link from "next/link";
5
5
  import { useRouter } from "next/navigation";
6
6
  import {
7
7
  ArrowLeft,
8
- Trash2,
9
8
  Save,
10
9
  AlertTriangle,
11
10
  Pencil,
@@ -23,12 +22,12 @@ import {
23
22
  } from "lucide-react";
24
23
  import { PageHeader } from "@/components/common/page-header";
25
24
  import { EmptyState } from "@/components/common/empty-state";
25
+ import { DeleteLearningDangerZone } from "@/components/common/delete-learning-danger-zone";
26
26
  import { Button } from "@/components/ui/button";
27
27
  import { Label } from "@/components/ui/label";
28
28
  import { Badge } from "@/components/ui/badge";
29
29
  import { Separator } from "@/components/ui/separator";
30
30
  import { reflexio } from "@/lib/reflexio-client";
31
- import { useSettings } from "@/hooks/use-settings";
32
31
  import { formatTimestamp, truncateId } from "@/lib/format";
33
32
  import { cn } from "@/lib/utils";
34
33
  import { statusLabel as status } from "@/lib/status";
@@ -42,7 +41,6 @@ export default function PreferenceDetailPage({
42
41
  const { id: rawId } = use(params);
43
42
  const id = decodeURIComponent(rawId);
44
43
  const router = useRouter();
45
- const { reflexioUrl } = useSettings();
46
44
 
47
45
  const [profile, setProfile] = useState<UserProfile | null>(null);
48
46
  const [notFound, setNotFound] = useState(false);
@@ -55,7 +53,7 @@ export default function PreferenceDetailPage({
55
53
  useEffect(() => {
56
54
  let cancelled = false;
57
55
  reflexio
58
- .getAllProfiles({ reflexioUrl, limit: 500 })
56
+ .getAllProfiles({ limit: 500 })
59
57
  .then((res) => {
60
58
  if (cancelled) return;
61
59
  const found = (res.user_profiles ?? []).find(
@@ -74,7 +72,7 @@ export default function PreferenceDetailPage({
74
72
  return () => {
75
73
  cancelled = true;
76
74
  };
77
- }, [id, reflexioUrl]);
75
+ }, [id]);
78
76
 
79
77
  const dirty = useMemo(
80
78
  () => !!profile && profile.content !== content,
@@ -92,7 +90,6 @@ export default function PreferenceDetailPage({
92
90
  profile_id: profile.profile_id,
93
91
  content,
94
92
  },
95
- reflexioUrl,
96
93
  );
97
94
  setProfile({ ...profile, content });
98
95
  setEditing(false);
@@ -105,12 +102,10 @@ export default function PreferenceDetailPage({
105
102
 
106
103
  const remove = async () => {
107
104
  if (!profile) return;
108
- if (!confirm("Delete this preference? This cannot be undone.")) return;
109
105
  setDeleting(true);
110
106
  try {
111
107
  await reflexio.deleteUserProfile(
112
108
  { user_id: profile.user_id, profile_id: profile.profile_id },
113
- reflexioUrl,
114
109
  );
115
110
  router.push("/preferences");
116
111
  } catch (e) {
@@ -200,7 +195,7 @@ export default function PreferenceDetailPage({
200
195
  <div className="mx-auto max-w-5xl grid gap-6 lg:grid-cols-[1fr_280px]">
201
196
  <div className="space-y-6 min-w-0">
202
197
  {error && (
203
- <div className="rounded-xl border border-destructive/30 bg-destructive/5 text-destructive px-4 py-3 text-sm flex items-start gap-2">
198
+ <div className="rounded-lg border border-destructive/30 bg-destructive/5 text-destructive px-4 py-3 text-sm flex items-start gap-2">
204
199
  <AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
205
200
  <span>{error}</span>
206
201
  </div>
@@ -237,7 +232,7 @@ export default function PreferenceDetailPage({
237
232
  onChange={(e) => setContent(e.target.value)}
238
233
  rows={6}
239
234
  placeholder="e.g. Project bans pytest-asyncio; uses anyio with trio backend for async tests."
240
- className="w-full rounded-xl border border-input bg-transparent px-4 py-3 text-sm leading-relaxed font-sans resize-y outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 placeholder:text-muted-foreground"
235
+ className="w-full rounded-lg border border-input bg-transparent px-4 py-3 text-sm leading-relaxed font-sans resize-y outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 placeholder:text-muted-foreground"
241
236
  />
242
237
  ) : (
243
238
  <Prose text={profile?.content ?? ""} />
@@ -270,7 +265,7 @@ export default function PreferenceDetailPage({
270
265
  title="Custom features"
271
266
  hint="Structured metadata attached to this preference."
272
267
  >
273
- <div className="rounded-xl border border-border bg-card overflow-hidden">
268
+ <div className="rounded-lg border border-border bg-card overflow-hidden">
274
269
  <dl className="divide-y divide-border">
275
270
  {customEntries.map(([k, v]) => (
276
271
  <div
@@ -306,8 +301,8 @@ export default function PreferenceDetailPage({
306
301
 
307
302
  {profile && (
308
303
  <aside className="space-y-3 lg:sticky lg:top-6 lg:self-start">
309
- <div className="rounded-xl border border-border bg-card p-4">
310
- <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">
304
+ <div className="rounded-lg border border-border bg-card p-4">
305
+ <h3 className="text-xs font-semibold uppercase text-muted-foreground mb-3">
311
306
  Metadata
312
307
  </h3>
313
308
  <dl className="space-y-2.5 text-sm">
@@ -391,7 +386,7 @@ function Prose({ text }: { text: string }) {
391
386
  return <p className="text-sm text-muted-foreground italic">—</p>;
392
387
  }
393
388
  return (
394
- <div className="rounded-xl border border-border bg-card px-4 py-3">
389
+ <div className="rounded-lg border border-border bg-card px-4 py-3">
395
390
  <p className="text-sm leading-relaxed whitespace-pre-wrap break-words">
396
391
  {text}
397
392
  </p>
@@ -509,23 +504,17 @@ function DangerZone({
509
504
  disabled: boolean;
510
505
  }) {
511
506
  return (
512
- <section className="rounded-xl border border-destructive/30 bg-destructive/5 p-4 flex items-start justify-between gap-4">
513
- <div className="min-w-0">
514
- <h3 className="text-sm font-semibold text-destructive">Danger zone</h3>
515
- <p className="text-xs text-muted-foreground mt-0.5">
516
- Deleting removes this preference permanently. Preferences regenerate from
517
- fresh interactions, so this is safe but not reversible.
518
- </p>
519
- </div>
520
- <Button
521
- variant="destructive"
522
- size="sm"
523
- onClick={onDelete}
524
- disabled={deleting || disabled}
525
- >
526
- <Trash2 className="h-3.5 w-3.5" />
527
- {deleting ? "Deleting…" : "Delete"}
528
- </Button>
529
- </section>
507
+ <DeleteLearningDangerZone
508
+ learningName="this preference"
509
+ description="Delete this claude-smart preference permanently."
510
+ consequences={[
511
+ "This preference will stop being applied in future claude-smart responses.",
512
+ "Historical session records may still show prior uses.",
513
+ "Future interactions may learn a similar preference again, but this record cannot be restored.",
514
+ ]}
515
+ onDelete={onDelete}
516
+ deleting={deleting}
517
+ disabled={disabled}
518
+ />
530
519
  );
531
520
  }
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useState } from "react";
3
+ import { useEffect, useMemo, useState } from "react";
4
4
  import Link from "next/link";
5
5
  import { Users, ChevronRight } from "lucide-react";
6
6
  import { PageHeader } from "@/components/common/page-header";
@@ -8,40 +8,92 @@ import { EmptyState } from "@/components/common/empty-state";
8
8
  import { DeleteAllButton } from "@/components/common/delete-all-button";
9
9
  import { Badge } from "@/components/ui/badge";
10
10
  import { Input } from "@/components/ui/input";
11
+ import {
12
+ Select,
13
+ SelectContent,
14
+ SelectItem,
15
+ SelectTrigger,
16
+ SelectValue,
17
+ } from "@/components/ui/select";
11
18
  import { reflexio } from "@/lib/reflexio-client";
12
- import { useSettings } from "@/hooks/use-settings";
13
19
  import { formatRelative, truncateId } from "@/lib/format";
14
- import type { UserProfile } from "@/lib/types";
20
+ import type { PlaybookApplicationStat, UserProfile } from "@/lib/types";
21
+
22
+ type PreferenceSort = "newest" | "applied";
23
+
24
+ function profileStatKey(profile: UserProfile): string {
25
+ return `profile:profile:${profile.profile_id}`;
26
+ }
15
27
 
16
28
  export default function PreferencesPage() {
17
- const { reflexioUrl } = useSettings();
18
29
  const [profiles, setProfiles] = useState<UserProfile[] | null>(null);
30
+ const [appStats, setAppStats] = useState<PlaybookApplicationStat[] | null>(
31
+ null,
32
+ );
19
33
  const [error, setError] = useState<string | null>(null);
34
+ const [sortBy, setSortBy] = useState<PreferenceSort>("newest");
20
35
  const [filter, setFilter] = useState("");
21
36
 
22
37
  useEffect(() => {
23
38
  let cancelled = false;
24
- reflexio
25
- .getAllProfiles({ reflexioUrl })
26
- .then((res) => {
39
+ async function load() {
40
+ try {
41
+ const [profileRes, statsRes] = await Promise.all([
42
+ reflexio.getAllProfiles(),
43
+ fetch("/api/rules/applied?daysBack=30&limit=200", {
44
+ cache: "no-store",
45
+ })
46
+ .then((r) => r.json())
47
+ .catch(() => ({
48
+ success: false,
49
+ stats: [] as PlaybookApplicationStat[],
50
+ })),
51
+ ]);
52
+ if (cancelled) return;
53
+ setProfiles(profileRes.user_profiles ?? []);
54
+ setAppStats(statsRes.stats ?? []);
55
+ setError(null);
56
+ } catch (e) {
27
57
  if (!cancelled) {
28
- setProfiles(res.user_profiles ?? []);
29
- setError(null);
58
+ setError(e instanceof Error ? e.message : String(e));
30
59
  }
31
- })
32
- .catch((e) => {
33
- if (!cancelled) setError(e instanceof Error ? e.message : String(e));
34
- });
60
+ }
61
+ }
62
+ load();
35
63
  return () => {
36
64
  cancelled = true;
37
65
  };
38
- }, [reflexioUrl]);
66
+ }, []);
39
67
 
40
- const filtered = (profiles ?? []).filter(
41
- (p) =>
42
- p.content.toLowerCase().includes(filter.toLowerCase()) ||
43
- p.user_id.toLowerCase().includes(filter.toLowerCase()),
44
- );
68
+ const statsByProfile = useMemo(() => {
69
+ const map = new Map<string, PlaybookApplicationStat>();
70
+ for (const s of appStats ?? []) {
71
+ map.set(`${s.kind}:${s.source_kind ?? "unknown"}:${s.real_id}`, s);
72
+ }
73
+ return map;
74
+ }, [appStats]);
75
+
76
+ const filtered = useMemo(() => {
77
+ const query = filter.toLowerCase();
78
+ const matches = (profiles ?? []).filter(
79
+ (p) =>
80
+ p.content.toLowerCase().includes(query) ||
81
+ p.user_id.toLowerCase().includes(query),
82
+ );
83
+ return matches.sort((a, b) => {
84
+ if (sortBy === "applied") {
85
+ const aStat = statsByProfile.get(profileStatKey(a));
86
+ const bStat = statsByProfile.get(profileStatKey(b));
87
+ const appliedDelta =
88
+ (bStat?.applied_count ?? 0) - (aStat?.applied_count ?? 0);
89
+ if (appliedDelta !== 0) return appliedDelta;
90
+ const recencyDelta =
91
+ (bStat?.last_applied_at ?? 0) - (aStat?.last_applied_at ?? 0);
92
+ if (recencyDelta !== 0) return recencyDelta;
93
+ }
94
+ return b.last_modified_timestamp - a.last_modified_timestamp;
95
+ });
96
+ }, [filter, profiles, sortBy, statsByProfile]);
45
97
 
46
98
  return (
47
99
  <div className="flex-1 overflow-auto">
@@ -49,19 +101,33 @@ export default function PreferencesPage() {
49
101
  title="Preferences"
50
102
  description="Project-scoped preferences extracted from interactions."
51
103
  actions={
52
- <div className="flex items-center gap-2">
104
+ <div className="flex max-w-full flex-wrap items-center justify-end gap-2">
105
+ <Select
106
+ value={sortBy}
107
+ onValueChange={(v) => setSortBy((v as PreferenceSort) ?? "newest")}
108
+ >
109
+ <SelectTrigger size="sm" className="w-36 text-xs bg-background/80">
110
+ <SelectValue placeholder="Sort">
111
+ {sortBy === "applied" ? "Most applied" : "Newest"}
112
+ </SelectValue>
113
+ </SelectTrigger>
114
+ <SelectContent>
115
+ <SelectItem value="newest">Newest</SelectItem>
116
+ <SelectItem value="applied">Most applied</SelectItem>
117
+ </SelectContent>
118
+ </Select>
53
119
  <Input
54
120
  value={filter}
55
121
  onChange={(e) => setFilter(e.target.value)}
56
122
  placeholder="Filter"
57
- className="h-8 w-56 text-xs"
123
+ className="h-9 w-56 text-xs bg-background/80"
58
124
  />
59
125
  <DeleteAllButton
60
126
  label={`Delete all${profiles && profiles.length > 0 ? ` (${profiles.length})` : ""}`}
61
127
  confirmMessage={`Delete ALL ${profiles?.length ?? 0} preferences? Preferences regenerate from fresh interactions, but this cannot be undone.`}
62
128
  disabled={!profiles || profiles.length === 0}
63
129
  onConfirm={async () => {
64
- await reflexio.deleteAllProfiles(reflexioUrl);
130
+ await reflexio.deleteAllProfiles();
65
131
  setProfiles([]);
66
132
  }}
67
133
  />
@@ -72,7 +138,7 @@ export default function PreferencesPage() {
72
138
  <div className="p-6">
73
139
  {error && (
74
140
  <div className="rounded-lg border border-destructive/30 bg-destructive/5 text-destructive px-4 py-3 text-sm mb-4">
75
- {error}. Is reflexio running on the URL in the top bar?
141
+ {error}. Is reflexio running on the configured backend URL?
76
142
  </div>
77
143
  )}
78
144
 
@@ -85,42 +151,76 @@ export default function PreferencesPage() {
85
151
  description="Keep using Claude with claude-smart enabled — preferences will appear here automatically as the extractor learns patterns from your interactions."
86
152
  />
87
153
  ) : (
88
- <div className="grid gap-3 md:grid-cols-2">
89
- {filtered.map((p) => (
90
- <Link
91
- key={p.profile_id}
92
- href={`/preferences/${encodeURIComponent(p.profile_id)}`}
93
- className="group block rounded-xl border border-border bg-card p-4 hover:bg-accent/40 transition-colors"
94
- >
95
- <header className="flex items-center justify-between gap-2 mb-2">
96
- <div className="flex items-center gap-2 min-w-0">
97
- <Badge variant="outline" className="h-5 font-mono text-[10px]">
98
- {truncateId(p.user_id, 32, 8)}
99
- </Badge>
100
- {p.status && (
101
- <Badge variant="secondary" className="h-5 text-[10px]">
102
- {p.status}
154
+ <div className="overflow-hidden rounded-lg border border-border bg-card/92 shadow-sm">
155
+ {filtered.map((p) => {
156
+ const stat = statsByProfile.get(profileStatKey(p));
157
+ return (
158
+ <Link
159
+ key={p.profile_id}
160
+ href={`/preferences/project/${encodeURIComponent(p.profile_id)}`}
161
+ className="group block border-b border-border px-4 py-3.5 transition-colors last:border-b-0 hover:bg-accent/35"
162
+ >
163
+ <header className="mb-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
164
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
165
+ <Badge variant="outline" className="h-5 font-mono text-[10px]">
166
+ {truncateId(p.user_id, 32, 8)}
103
167
  </Badge>
104
- )}
105
- </div>
106
- <div className="flex items-center gap-1.5 shrink-0">
107
- <span className="text-[11px] text-muted-foreground">
108
- {formatRelative(p.last_modified_timestamp)}
109
- </span>
110
- <ChevronRight className="h-3.5 w-3.5 text-muted-foreground/60 group-hover:text-foreground transition-colors" />
111
- </div>
112
- </header>
113
- <p className="text-sm leading-relaxed line-clamp-4">{p.content}</p>
114
- {p.source && (
115
- <p className="text-[11px] text-muted-foreground mt-2 font-mono">
116
- source: {p.source}
168
+ {p.status && (
169
+ <Badge variant="secondary" className="h-5 text-[10px]">
170
+ {p.status}
171
+ </Badge>
172
+ )}
173
+ <PreferenceApplicationStatBadge stat={stat} />
174
+ </div>
175
+ <div className="flex items-center gap-1.5 shrink-0">
176
+ <span className="text-[11px] text-muted-foreground">
177
+ {formatRelative(p.last_modified_timestamp)}
178
+ </span>
179
+ <ChevronRight className="h-3.5 w-3.5 text-muted-foreground/60 group-hover:text-foreground transition-colors" />
180
+ </div>
181
+ </header>
182
+ <p className="max-w-5xl text-sm leading-relaxed line-clamp-3">
183
+ {p.content}
117
184
  </p>
118
- )}
119
- </Link>
120
- ))}
185
+ {p.source && (
186
+ <p className="text-[11px] text-muted-foreground mt-2 font-mono">
187
+ source: {p.source}
188
+ </p>
189
+ )}
190
+ </Link>
191
+ );
192
+ })}
121
193
  </div>
122
194
  )}
123
195
  </div>
124
196
  </div>
125
197
  );
126
198
  }
199
+
200
+ function PreferenceApplicationStatBadge({
201
+ stat,
202
+ }: {
203
+ stat: PlaybookApplicationStat | undefined;
204
+ }) {
205
+ if (!stat || stat.applied_count === 0) {
206
+ return (
207
+ <Badge
208
+ variant="outline"
209
+ className="h-5 text-[10px] text-muted-foreground"
210
+ title="No citations recorded yet for this preference. It will count once an assistant reply cites it."
211
+ >
212
+ Never applied
213
+ </Badge>
214
+ );
215
+ }
216
+ const last = formatRelative(stat.last_applied_at);
217
+ return (
218
+ <Badge
219
+ variant="secondary"
220
+ className="h-5 text-[10px]"
221
+ title={`Last applied ${last}`}
222
+ >
223
+ Applied {stat.applied_count}×{stat.last_applied_at ? ` · ${last}` : ""}
224
+ </Badge>
225
+ );
226
+ }
@@ -0,0 +1 @@
1
+ export { default } from "../../[id]/page";