@agentprojectcontext/apx 1.48.2 → 1.49.0

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.
@@ -18,7 +18,7 @@
18
18
  <link rel="apple-touch-icon" href="/favicon/dark/apple-touch-icon.png" media="(prefers-color-scheme: dark)" />
19
19
  <link rel="manifest" href="/favicon/white/site.webmanifest" media="(prefers-color-scheme: light)" />
20
20
  <link rel="manifest" href="/favicon/dark/site.webmanifest" media="(prefers-color-scheme: dark)" />
21
- <script type="module" crossorigin src="/assets/index-Bgu-xy_L.js"></script>
21
+ <script type="module" crossorigin src="/assets/index-lu6lfr1N.js"></script>
22
22
  <link rel="stylesheet" crossorigin href="/assets/index-C53eJujd.css">
23
23
  </head>
24
24
  <body class="bg-background text-foreground antialiased">
@@ -79,6 +79,21 @@ export function RobyBubble({
79
79
  // Abort any in-flight stream on unmount.
80
80
  useEffect(() => () => abortRef.current?.abort(), []);
81
81
 
82
+ // Open the quick chat with a pre-filled prompt when another screen asks for
83
+ // it (e.g. the Sessions tab's "ask {persona} to continue this session"). The
84
+ // dispatcher passes the text; we open and load it without auto-sending so the
85
+ // user can add their own instructions first.
86
+ useEffect(() => {
87
+ const onPreload = (e: Event) => {
88
+ const text = (e as CustomEvent<{ prompt?: string }>).detail?.prompt;
89
+ if (typeof text !== "string") return;
90
+ onOpenChange(true);
91
+ setDraft(text);
92
+ };
93
+ window.addEventListener("apx:roby-prompt", onPreload);
94
+ return () => window.removeEventListener("apx:roby-prompt", onPreload);
95
+ }, [onOpenChange]);
96
+
82
97
  const stop = () => {
83
98
  abortRef.current?.abort();
84
99
  setBusy(false);
@@ -718,6 +718,22 @@ export const en = {
718
718
  sessions_all: "All engines",
719
719
  sessions_empty: "No sessions.",
720
720
  sessions_error: "Could not read sessions: {msg}",
721
+ sessions_search_ph: "Search sessions…",
722
+ sessions_deep: "Deep",
723
+ sessions_deep_tip: "Also search inside transcripts (slower)",
724
+ sessions_clear: "Clear filters",
725
+ sessions_refresh: "Refresh list",
726
+ sessions_no_match: "No sessions match “{q}”.",
727
+ sessions_act_cmd: "Copy apx command",
728
+ sessions_act_ask: "Ask {name} to continue",
729
+ sessions_act_folder:"Open folder",
730
+ sessions_act_path: "Copy path",
731
+ sessions_cmd_copied:"Command copied — paste it in your terminal",
732
+ sessions_path_copied:"Path copied",
733
+ sessions_copy_failed:"Could not copy",
734
+ sessions_no_folder: "This session has no folder",
735
+ sessions_no_path: "This session has no path",
736
+ sessions_folder_failed:"Could not open folder: {msg}",
721
737
  defaults_title: "Agent defaults",
722
738
  defaults_desc: "Global vault templates. Bundled ones come with APX and are always present; ones you create or edit go in ~/.apx/agents and override. Import them into a project from Agents › Import.",
723
739
  defaults_show_removed: "Show removed",
@@ -716,6 +716,22 @@ export const es = {
716
716
  sessions_all: "Todos los engines",
717
717
  sessions_empty: "Sin sesiones.",
718
718
  sessions_error: "No pude leer las sesiones: {msg}",
719
+ sessions_search_ph: "Buscar sesiones…",
720
+ sessions_deep: "Profundo",
721
+ sessions_deep_tip: "También busca dentro de los transcripts (más lento)",
722
+ sessions_clear: "Limpiar filtros",
723
+ sessions_refresh: "Refrescar lista",
724
+ sessions_no_match: "Ninguna sesión coincide con «{q}».",
725
+ sessions_act_cmd: "Copiar comando apx",
726
+ sessions_act_ask: "Pedir a {name} que continúe",
727
+ sessions_act_folder:"Abrir carpeta",
728
+ sessions_act_path: "Copiar ruta",
729
+ sessions_cmd_copied:"Comando copiado — pegalo en tu terminal",
730
+ sessions_path_copied:"Ruta copiada",
731
+ sessions_copy_failed:"No se pudo copiar",
732
+ sessions_no_folder: "Esta sesión no tiene carpeta",
733
+ sessions_no_path: "Esta sesión no tiene ruta",
734
+ sessions_folder_failed:"No se pudo abrir la carpeta: {msg}",
719
735
  defaults_title: "Agent defaults",
720
736
  defaults_desc: "Plantillas globales del vault. Las bundled vienen con APX y siempre están; las que crees o edites quedan en ~/.apx/agents y se superponen. Importalas a un proyecto desde Agents › Importar.",
721
737
  defaults_show_removed: "Mostrar removidos",
@@ -7,6 +7,8 @@ export interface SessionRow {
7
7
  mtime: number;
8
8
  cwd: string;
9
9
  path: string | null;
10
+ // Present only on search results: where the query matched.
11
+ match?: "title" | "content";
10
12
  }
11
13
 
12
14
  export const Sessions = {
@@ -15,10 +17,13 @@ export const Sessions = {
15
17
  http
16
18
  .get<unknown>(`/sessions${engine ? `?engine=${encodeURIComponent(engine)}` : ""}`)
17
19
  .then((b) => ({ sessions: unwrapPage<SessionRow>(b).items })),
18
- // Server-paginated page: returns the requested window plus the full total.
19
- page: ({ engine, limit, offset }: { engine?: string; limit: number; offset: number }) => {
20
- const q = new URLSearchParams({ limit: String(limit), offset: String(offset) });
21
- if (engine) q.set("engine", engine);
22
- return http.get<unknown>(`/sessions?${q.toString()}`).then((b) => unwrapPage<SessionRow>(b));
20
+ // Server-paginated page. Optional `q` runs the same search core as
21
+ // `apx session find` (title; + transcript content when `deep`).
22
+ page: ({ engine, q, deep, limit, offset }: { engine?: string; q?: string; deep?: boolean; limit: number; offset: number }) => {
23
+ const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
24
+ if (engine) params.set("engine", engine);
25
+ if (q?.trim()) params.set("q", q.trim());
26
+ if (deep) params.set("deep", "1");
27
+ return http.get<unknown>(`/sessions?${params.toString()}`).then((b) => unwrapPage<SessionRow>(b));
23
28
  },
24
29
  };
@@ -1,10 +1,12 @@
1
- import { useState } from "react";
2
- import { RefreshCw } from "lucide-react";
3
- import { Sessions } from "../../lib/api";
1
+ import { useEffect, useState } from "react";
2
+ import { RefreshCw, Search, X, Terminal, Bot, FolderOpen, Copy } from "lucide-react";
3
+ import { Sessions, Deck, type SessionRow } from "../../lib/api";
4
4
  import { Section } from "../../components/Section";
5
5
  import { PagedList, usePagedQuery } from "../../components/Pager";
6
- import { Badge, Button, Empty, Loading } from "../../components/ui";
6
+ import { Badge, Button, Empty, Input, Loading, Tip } from "../../components/ui";
7
7
  import { UiSelect } from "../../components/UiSelect";
8
+ import { useToast } from "../../components/Toast";
9
+ import { usePersonaName } from "../../hooks/usePersonaName";
8
10
  import { t } from "../../i18n";
9
11
 
10
12
  const ENGINE_TONE: Record<string, "success" | "info" | "warning" | "muted"> = {
@@ -12,49 +14,137 @@ const ENGINE_TONE: Record<string, "success" | "info" | "warning" | "muted"> = {
12
14
  };
13
15
 
14
16
  export function SessionsTab() {
17
+ const toast = useToast();
18
+ const persona = usePersonaName();
15
19
  const [engine, setEngine] = useState("");
16
- const paged = usePagedQuery({
17
- key: `/sessions?engine=${engine}`,
18
- fetchPage: (limit, offset) => Sessions.page({ engine: engine || undefined, limit, offset }),
19
- resetKey: engine,
20
+ const [input, setInput] = useState("");
21
+ const [query, setQuery] = useState("");
22
+ const [deep, setDeep] = useState(false);
23
+
24
+ // Debounce the raw input so we don't hit the search core on every keystroke.
25
+ useEffect(() => {
26
+ const id = setTimeout(() => setQuery(input.trim()), 350);
27
+ return () => clearTimeout(id);
28
+ }, [input]);
29
+
30
+ const paged = usePagedQuery<SessionRow>({
31
+ key: `/sessions?engine=${engine}&q=${query}&deep=${deep ? 1 : 0}`,
32
+ fetchPage: (limit, offset) =>
33
+ Sessions.page({ engine: engine || undefined, q: query || undefined, deep, limit, offset }),
34
+ resetKey: `${engine}|${query}|${deep ? 1 : 0}`,
20
35
  });
21
36
 
37
+ const clear = () => { setInput(""); setQuery(""); setEngine(""); setDeep(false); };
38
+
39
+ // ── per-row actions (all reuse existing system functions) ──────────────────
40
+ const copyCmd = async (s: SessionRow) => {
41
+ // Same command a user would run in the terminal to resume the session.
42
+ try {
43
+ await navigator.clipboard.writeText(`apx session resume ${s.id} --continue`);
44
+ toast.success(t("base.sessions_cmd_copied"));
45
+ } catch { toast.error(t("base.sessions_copy_failed")); }
46
+ };
47
+
48
+ const askPersona = (s: SessionRow) => {
49
+ // English on purpose: the super-agent's language is unknown, and the user
50
+ // appends their own instructions after the colon.
51
+ const prompt =
52
+ `Continue this session: ${s.id} ` +
53
+ `(engine: ${s.engine}${s.title ? `, title: "${s.title}"` : ""}${s.cwd ? `, folder: ${s.cwd}` : ""}). ` +
54
+ `With these instructions: `;
55
+ window.dispatchEvent(new CustomEvent("apx:roby-prompt", { detail: { prompt } }));
56
+ };
57
+
58
+ const openFolder = async (s: SessionRow) => {
59
+ if (!s.cwd) { toast.error(t("base.sessions_no_folder")); return; }
60
+ try { await Deck.exec({ kind: "open_path", target: s.cwd }); }
61
+ catch (e) { toast.error(t("base.sessions_folder_failed", { msg: (e as Error).message })); }
62
+ };
63
+
64
+ const copyPath = async (s: SessionRow) => {
65
+ const p = s.path || s.cwd;
66
+ if (!p) { toast.error(t("base.sessions_no_path")); return; }
67
+ try { await navigator.clipboard.writeText(p); toast.success(t("base.sessions_path_copied")); }
68
+ catch { toast.error(t("base.sessions_copy_failed")); }
69
+ };
70
+
22
71
  return (
23
72
  <Section
24
73
  fullHeight
25
74
  title={t("base.sessions_title")}
26
75
  description={t("base.sessions_desc")}
27
76
  action={
28
- <div className="flex items-center gap-2">
29
- <div className="w-40">
30
- <UiSelect
31
- value={engine}
32
- onChange={setEngine}
33
- options={[
34
- { value: "", label: t("base.sessions_all") },
35
- { value: "apx", label: "apx" },
36
- { value: "claude", label: "claude" },
37
- { value: "codex", label: "codex" },
38
- ]}
39
- />
40
- </div>
77
+ <Tip content={t("base.sessions_refresh")}>
41
78
  <Button size="sm" variant="secondary" onClick={() => paged.mutate()}><RefreshCw size={13} /></Button>
42
- </div>
79
+ </Tip>
43
80
  }
44
81
  >
82
+ {/* Toolbar: search + deep toggle + engine selector + clear. */}
83
+ <div className="mb-3 flex items-center gap-2">
84
+ <div className="relative flex-1">
85
+ <Search size={14} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-fg" />
86
+ <Input
87
+ className="pl-8"
88
+ placeholder={t("base.sessions_search_ph")}
89
+ value={input}
90
+ onChange={(e) => setInput(e.target.value)}
91
+ />
92
+ </div>
93
+ <Tip content={t("base.sessions_deep_tip")}>
94
+ <Button size="sm" variant={deep ? "primary" : "secondary"} onClick={() => setDeep((d) => !d)}>
95
+ {t("base.sessions_deep")}
96
+ </Button>
97
+ </Tip>
98
+ <div className="w-36">
99
+ <UiSelect
100
+ value={engine}
101
+ onChange={setEngine}
102
+ options={[
103
+ { value: "", label: t("base.sessions_all") },
104
+ { value: "apx", label: "apx" },
105
+ { value: "claude", label: "claude" },
106
+ { value: "codex", label: "codex" },
107
+ ]}
108
+ />
109
+ </div>
110
+ <Tip content={t("base.sessions_clear")}>
111
+ <Button size="sm" variant="ghost" onClick={clear}><X size={14} /></Button>
112
+ </Tip>
113
+ </div>
114
+
45
115
  {paged.isLoading && <Loading />}
46
116
  {paged.error && <Empty>{t("base.sessions_error", { msg: (paged.error as Error).message })}</Empty>}
47
- {!paged.isLoading && !paged.error && paged.total === 0 && <Empty>{t("base.sessions_empty")}</Empty>}
117
+ {!paged.isLoading && !paged.error && paged.total === 0 && (
118
+ <Empty>{query ? t("base.sessions_no_match", { q: query }) : t("base.sessions_empty")}</Empty>
119
+ )}
120
+
48
121
  <PagedList paged={paged} fullHeight>
49
122
  <ul className="space-y-1 text-sm">
50
123
  {paged.items.map((s, i) => (
51
- <li key={`${s.engine}-${s.id}-${i}`} className="flex items-center gap-3 rounded-md border border-border bg-muted/30 px-3 py-2">
124
+ <li key={`${s.engine}-${s.id}-${i}`} className="group flex items-center gap-3 rounded-md border border-border bg-muted/30 px-3 py-2">
52
125
  <Badge tone={ENGINE_TONE[s.engine] || "muted"}>{s.engine}</Badge>
53
126
  <div className="min-w-0 flex-1">
54
127
  <div className="truncate">{s.title || s.id}</div>
55
- <div className="truncate font-mono text-[10px] text-muted-fg">{s.cwd}</div>
128
+ <div className="flex items-center gap-2 font-mono text-[10px] text-muted-fg">
129
+ <span className="shrink-0">{s.id}</span>
130
+ {s.cwd && <span className="truncate">· {s.cwd}</span>}
131
+ </div>
56
132
  </div>
57
133
  {s.mtime > 0 && <span className="shrink-0 text-[11px] text-muted-fg">{new Date(s.mtime).toLocaleString()}</span>}
134
+ <div className="flex shrink-0 items-center gap-0.5 opacity-60 transition-opacity group-hover:opacity-100">
135
+ <Tip content={t("base.sessions_act_cmd")}>
136
+ <Button size="sm" variant="ghost" aria-label={t("base.sessions_act_cmd")} onClick={() => copyCmd(s)}><Terminal size={13} /></Button>
137
+ </Tip>
138
+ <Tip content={t("base.sessions_act_ask", { name: persona })}>
139
+ <Button size="sm" variant="ghost" aria-label={t("base.sessions_act_ask", { name: persona })} onClick={() => askPersona(s)}><Bot size={13} /></Button>
140
+ </Tip>
141
+ <Tip content={t("base.sessions_act_folder")}>
142
+ <Button size="sm" variant="ghost" aria-label={t("base.sessions_act_folder")} onClick={() => openFolder(s)}><FolderOpen size={13} /></Button>
143
+ </Tip>
144
+ <Tip content={t("base.sessions_act_path")}>
145
+ <Button size="sm" variant="ghost" aria-label={t("base.sessions_act_path")} onClick={() => copyPath(s)}><Copy size={13} /></Button>
146
+ </Tip>
147
+ </div>
58
148
  </li>
59
149
  ))}
60
150
  </ul>