@agentprojectcontext/apx 1.38.1 → 1.39.1

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,8 +18,8 @@
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-CQc_5t8F.js"></script>
22
- <link rel="stylesheet" crossorigin href="/assets/index-hwxuTPcK.css">
21
+ <script type="module" crossorigin src="/assets/index-UzqHxD0B.js"></script>
22
+ <link rel="stylesheet" crossorigin href="/assets/index-CAKEYko0.css">
23
23
  </head>
24
24
  <body class="bg-background text-foreground antialiased">
25
25
  <div id="root"></div>
@@ -905,13 +905,13 @@
905
905
  }
906
906
  },
907
907
  "node_modules/@playwright/test": {
908
- "version": "1.60.0",
909
- "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
910
- "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
908
+ "version": "1.61.0",
909
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz",
910
+ "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==",
911
911
  "dev": true,
912
912
  "license": "Apache-2.0",
913
913
  "dependencies": {
914
- "playwright": "1.60.0"
914
+ "playwright": "1.61.0"
915
915
  },
916
916
  "bin": {
917
917
  "playwright": "cli.js"
@@ -3286,13 +3286,13 @@
3286
3286
  }
3287
3287
  },
3288
3288
  "node_modules/playwright": {
3289
- "version": "1.60.0",
3290
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
3291
- "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
3289
+ "version": "1.61.0",
3290
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
3291
+ "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
3292
3292
  "dev": true,
3293
3293
  "license": "Apache-2.0",
3294
3294
  "dependencies": {
3295
- "playwright-core": "1.60.0"
3295
+ "playwright-core": "1.61.0"
3296
3296
  },
3297
3297
  "bin": {
3298
3298
  "playwright": "cli.js"
@@ -3305,9 +3305,9 @@
3305
3305
  }
3306
3306
  },
3307
3307
  "node_modules/playwright-core": {
3308
- "version": "1.60.0",
3309
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
3310
- "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
3308
+ "version": "1.61.0",
3309
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
3310
+ "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
3311
3311
  "dev": true,
3312
3312
  "license": "Apache-2.0",
3313
3313
  "bin": {
@@ -1,6 +1,6 @@
1
1
  import { useState } from "react";
2
2
  import { Route, Routes, useLocation, useNavigate, useSearchParams } from "react-router-dom";
3
- import { Moon, Sun } from "lucide-react";
3
+ import { Languages, Moon, Sun } from "lucide-react";
4
4
  import { ProjectSidebar, projectKindLabel } from "./components/layout/ProjectSidebar";
5
5
  import { ApxAdminScreen } from "./screens/ApxAdminScreen";
6
6
  import { ProjectScreen } from "./screens/ProjectScreen";
@@ -21,7 +21,14 @@ import { useProjects } from "./hooks/useProjects";
21
21
  import { useTokenBootstrap } from "./hooks/useTokenBootstrap";
22
22
  import { NavCollapseProvider, useNavCollapseCtx, usePageActions, usePageLabel } from "./hooks/useNavCollapseCtx";
23
23
  import { NavToggle } from "./components/common/TabNav";
24
- import { t } from "./i18n";
24
+ import {
25
+ DropdownMenu,
26
+ DropdownMenuContent,
27
+ DropdownMenuRadioGroup,
28
+ DropdownMenuRadioItem,
29
+ DropdownMenuTrigger,
30
+ } from "./components/ui/dropdown-menu";
31
+ import { t, getLocale, setLocale, LOCALES, type Locale } from "./i18n";
25
32
 
26
33
  export function App() {
27
34
  const auth = useTokenBootstrap();
@@ -143,6 +150,7 @@ function TopBar({
143
150
  {subtitle && <span className="text-muted-fg/50"> · {subtitle}</span>}
144
151
  </span>
145
152
  {pageActions}
153
+ <LanguageMenu />
146
154
  <button
147
155
  type="button"
148
156
  data-testid="theme-toggle"
@@ -156,6 +164,37 @@ function TopBar({
156
164
  );
157
165
  }
158
166
 
167
+ function LanguageMenu() {
168
+ const current = getLocale();
169
+ const change = (l: Locale) => {
170
+ if (l === current) return;
171
+ setLocale(l);
172
+ // Reload so every rendered string picks up the new locale — t() is not reactive.
173
+ window.location.reload();
174
+ };
175
+ return (
176
+ <DropdownMenu>
177
+ <DropdownMenuTrigger
178
+ data-testid="lang-menu"
179
+ title={t("topbar.lang_toggle")}
180
+ className="flex shrink-0 items-center gap-1 rounded-md p-1.5 text-muted-fg hover:bg-accent hover:text-accent-fg"
181
+ >
182
+ <Languages size={14} />
183
+ <span className="text-[11px] font-medium uppercase">{current}</span>
184
+ </DropdownMenuTrigger>
185
+ <DropdownMenuContent align="end">
186
+ <DropdownMenuRadioGroup value={current} onValueChange={(v) => change(v as Locale)}>
187
+ {LOCALES.map((lo) => (
188
+ <DropdownMenuRadioItem key={lo.value} value={lo.value}>
189
+ {lo.label}
190
+ </DropdownMenuRadioItem>
191
+ ))}
192
+ </DropdownMenuRadioGroup>
193
+ </DropdownMenuContent>
194
+ </DropdownMenu>
195
+ );
196
+ }
197
+
159
198
  function moduleLabel(key?: string) {
160
199
  switch (key) {
161
200
  case "voice": return t("nav.modules.voice");
@@ -114,3 +114,11 @@ const TONE_ACTIVE: Record<ProjectTone, string> = {
114
114
  };
115
115
  function toneIdle(t: ProjectTone) { return TONE_IDLE[t]; }
116
116
  function toneActive(t: ProjectTone) { return TONE_ACTIVE[t]; }
117
+
118
+ /** Compact avatar tokens for list rows (overflow / collapsed menus). Reuses
119
+ * the same initials + tone as the rail so a project reads identically in both
120
+ * the rail and the popover. */
121
+ export function projectTone(name: string): { initials: string; idleClass: string } {
122
+ const { initials } = computeInitialsAndSub(name);
123
+ return { initials, idleClass: TONE_IDLE[pickTone(name)] };
124
+ }
@@ -2,14 +2,31 @@
2
2
  // rail-level MODULES (Voice/Deck/Code) that sit alongside Base, then the
3
3
  // projects column, finally add + settings. The default workspace (id=0) is
4
4
  // pinned first.
5
+ //
6
+ // The projects column is the only flexible zone: top (logo/base/modules) and
7
+ // bottom (add/settings/docs/roby) stay pinned. Projects are listed newest-first
8
+ // and only as many as physically fit are shown inline — the rest collapse into
9
+ // a "+N" popover so the rail never overflows the viewport. The whole section can
10
+ // also be collapsed into a single folder button (state persisted per browser).
11
+ import { useLayoutEffect, useRef, useState } from "react";
5
12
  import { useLocation } from "react-router-dom";
6
- import { Plus, Settings, Mic, Monitor, LayoutGrid, Terminal, Bot, BookOpen, type LucideIcon } from "lucide-react";
13
+ import { Plus, Settings, Mic, Monitor, LayoutGrid, Terminal, Bot, BookOpen, ChevronDown, Folders, type LucideIcon } from "lucide-react";
7
14
  import { Logo } from "./Logo";
8
- import { ProjectAvatar } from "./ProjectAvatar";
15
+ import { ProjectAvatar, projectTone } from "./ProjectAvatar";
9
16
  import { Tip } from "../ui/tip";
17
+ import {
18
+ DropdownMenu,
19
+ DropdownMenuContent,
20
+ DropdownMenuItem,
21
+ DropdownMenuTrigger,
22
+ } from "../ui/dropdown-menu";
23
+ import { useNavCollapse } from "../common/TabNav";
10
24
  import { useProjects } from "../../hooks/useProjects";
11
- import { t } from "../../i18n";
12
25
  import { usePersonaName } from "../../hooks/usePersonaName";
26
+ import { STORAGE } from "../../constants";
27
+ import { cn } from "../../lib/cn";
28
+ import { t } from "../../i18n";
29
+ import type { ProjectEntry } from "../../types/daemon";
13
30
 
14
31
  interface Props {
15
32
  onSelect: (href: string) => void;
@@ -34,19 +51,136 @@ function buildModules(): ModuleItem[] {
34
51
  ];
35
52
  }
36
53
 
54
+ // How many project avatars fit in the flexible list area. The list is `flex-1`,
55
+ // so its height is fixed by the surrounding chrome and does NOT depend on how
56
+ // many items we render — measuring it is therefore stable (no resize loop). The
57
+ // list also holds the always-present "Add" button (one slot) and, when there's
58
+ // overflow, the "+N" button (a second slot), so we reserve for those.
59
+ function useVisibleCount(
60
+ listRef: React.RefObject<HTMLDivElement | null>,
61
+ total: number,
62
+ enabled: boolean,
63
+ ): number {
64
+ const [count, setCount] = useState(total);
65
+ useLayoutEffect(() => {
66
+ const el = listRef.current;
67
+ if (!el || !enabled) return;
68
+ const measure = () => {
69
+ const h = el.clientHeight;
70
+ if (!h) return;
71
+ const gap = parseFloat(getComputedStyle(el).rowGap) || 12;
72
+ // A hidden, always-present probe gives an accurate item height even on the
73
+ // first paint or when zero real items currently fit.
74
+ const probe = el.querySelector<HTMLElement>("[data-rail-probe]");
75
+ const per = (probe?.offsetHeight ?? 56) + gap;
76
+ const slots = Math.max(0, Math.floor((h + gap) / per));
77
+ const forItems = slots - 1; // reserve the Add button
78
+ setCount(forItems >= total ? total : Math.max(0, forItems - 1)); // reserve "+N"
79
+ };
80
+ measure();
81
+ const ro = new ResizeObserver(measure);
82
+ ro.observe(el);
83
+ return () => ro.disconnect();
84
+ }, [listRef, total, enabled]);
85
+ return enabled ? Math.min(count, total) : total;
86
+ }
87
+
88
+ // Square rail button that opens a dropdown listing projects — used both for the
89
+ // "+N" overflow bucket and for the fully-collapsed folder.
90
+ function RailProjectMenu({
91
+ projects,
92
+ label,
93
+ sublabel,
94
+ icon,
95
+ tooltip,
96
+ header,
97
+ active,
98
+ testId,
99
+ onSelect,
100
+ isActive,
101
+ }: {
102
+ projects: ProjectEntry[];
103
+ label?: string;
104
+ sublabel?: string;
105
+ icon?: React.ReactNode;
106
+ tooltip: string;
107
+ header: string;
108
+ active: boolean;
109
+ testId: string;
110
+ onSelect: (href: string) => void;
111
+ isActive: (href: string) => boolean;
112
+ }) {
113
+ return (
114
+ <DropdownMenu>
115
+ <DropdownMenuTrigger
116
+ data-testid={testId}
117
+ title={tooltip}
118
+ aria-label={tooltip}
119
+ className="group flex w-full cursor-pointer flex-col items-center gap-1"
120
+ >
121
+ <span
122
+ className={cn(
123
+ "flex size-10 items-center justify-center rounded-xl text-xs font-bold transition-all",
124
+ "bg-muted/40 text-muted-fg hover:bg-accent hover:text-foreground",
125
+ active && "ring-2 ring-foreground ring-offset-2 ring-offset-card",
126
+ )}
127
+ >
128
+ {icon ?? label}
129
+ </span>
130
+ {sublabel && (
131
+ <span className="block max-w-[3.6rem] truncate text-[9px] leading-tight text-muted-fg group-hover:text-foreground">
132
+ {sublabel}
133
+ </span>
134
+ )}
135
+ </DropdownMenuTrigger>
136
+ <DropdownMenuContent side="right" align="start" sideOffset={8} className="max-h-[70vh] w-64">
137
+ <div className="px-1.5 py-1 text-xs font-medium text-muted-foreground">{header}</div>
138
+ {projects.map((p) => {
139
+ const name = p.name || p.path.split("/").pop() || String(p.id);
140
+ const href = `/p/${p.id}`;
141
+ const { initials, idleClass } = projectTone(name);
142
+ return (
143
+ <DropdownMenuItem
144
+ key={p.id}
145
+ data-testid={`project-menu-item-${p.id}`}
146
+ onClick={() => onSelect(href)}
147
+ className={cn(isActive(href) && "bg-accent/60 text-foreground")}
148
+ >
149
+ <span className={cn("flex size-6 shrink-0 items-center justify-center rounded-md text-[10px] font-bold", idleClass)}>
150
+ {initials}
151
+ </span>
152
+ <span className="truncate">{name}</span>
153
+ </DropdownMenuItem>
154
+ );
155
+ })}
156
+ </DropdownMenuContent>
157
+ </DropdownMenu>
158
+ );
159
+ }
160
+
37
161
  export function ProjectSidebar({ onSelect, onOpenRoby }: Props) {
38
162
  const { projects, isLoading } = useProjects();
39
163
  const location = useLocation();
40
164
  const MODULES = buildModules();
41
165
  const persona = usePersonaName();
166
+ const listRef = useRef<HTMLDivElement>(null);
167
+ const { collapsed, toggle } = useNavCollapse(STORAGE.sidebarCollapsed + ".projects");
42
168
 
43
169
  const isActive = (href: string) =>
44
170
  location.pathname === href || location.pathname.startsWith(`${href}/`);
45
171
  const base = projects.find((p) => String(p.id) === "0");
46
- const rest = projects.filter((p) => String(p.id) !== "0");
172
+ // Newest first higher ids are more recently registered.
173
+ const rest = projects
174
+ .filter((p) => String(p.id) !== "0")
175
+ .sort((a, b) => Number(b.id) - Number(a.id));
176
+
177
+ const visibleCount = useVisibleCount(listRef, rest.length, !collapsed && rest.length > 0);
178
+ const visible = rest.slice(0, visibleCount);
179
+ const overflow = rest.slice(visibleCount);
180
+ const overflowHasActive = overflow.some((p) => isActive(`/p/${p.id}`));
47
181
 
48
182
  return (
49
- <aside className="flex h-full w-20 flex-col items-center gap-3 overflow-y-auto bg-transparent py-3">
183
+ <aside className="flex h-full w-20 flex-col items-center gap-3 overflow-hidden bg-transparent py-3">
50
184
  <Tip content={t("nav.apx_admin")} side="right">
51
185
  <button
52
186
  type="button"
@@ -86,33 +220,95 @@ export function ProjectSidebar({ onSelect, onOpenRoby }: Props) {
86
220
  />
87
221
  ))}
88
222
 
89
- {rest.length > 0 && <div className="my-0.5 h-px w-8 rounded-full bg-border" />}
90
- {rest.map((p) => {
91
- const label = p.name || p.path.split("/").pop() || String(p.id);
92
- const href = `/p/${p.id}`;
93
- return (
223
+ {/* Projects column the only flexible zone. The measured list holds the
224
+ projects, the "+N" overflow bucket and the Add button; it fills the
225
+ remaining height so the bottom group (settings/docs/roby) stays pinned. */}
226
+ <div className="flex min-h-0 w-full flex-1 flex-col items-center gap-3">
227
+ {rest.length > 0 && (
228
+ <>
229
+ <div className="my-0.5 h-px w-8 rounded-full bg-border" />
230
+ <Tip content={collapsed ? t("nav.expand_projects") : t("nav.collapse_projects")} side="right">
231
+ <button
232
+ type="button"
233
+ onClick={toggle}
234
+ data-testid="nav-toggle-projects"
235
+ aria-label={collapsed ? t("nav.expand_projects") : t("nav.collapse_projects")}
236
+ aria-expanded={!collapsed}
237
+ className="flex h-5 w-8 cursor-pointer items-center justify-center rounded-md text-muted-fg transition-colors hover:bg-accent hover:text-foreground"
238
+ >
239
+ <ChevronDown className={cn("size-3.5 transition-transform", collapsed && "-rotate-90")} />
240
+ </button>
241
+ </Tip>
242
+ </>
243
+ )}
244
+
245
+ <div
246
+ ref={listRef}
247
+ className="flex min-h-0 w-full flex-1 flex-col items-center gap-3 overflow-hidden"
248
+ >
249
+ {rest.length > 0 && collapsed && (
250
+ <RailProjectMenu
251
+ projects={rest}
252
+ icon={<Folders size={18} />}
253
+ sublabel={String(rest.length)}
254
+ tooltip={t("nav.all_projects")}
255
+ header={t("nav.all_projects")}
256
+ active={rest.some((p) => isActive(`/p/${p.id}`))}
257
+ testId="nav-projects-folder"
258
+ onSelect={onSelect}
259
+ isActive={isActive}
260
+ />
261
+ )}
262
+
263
+ {rest.length > 0 && !collapsed && (
264
+ <>
265
+ {/* Hidden ruler — out of flow, measured to size the visible list
266
+ accurately regardless of how many items render. */}
267
+ <div data-rail-probe aria-hidden className="invisible absolute w-full">
268
+ <ProjectAvatar label="Ag" active={false} onClick={() => {}} />
269
+ </div>
270
+ {visible.map((p) => {
271
+ const label = p.name || p.path.split("/").pop() || String(p.id);
272
+ const href = `/p/${p.id}`;
273
+ return (
274
+ <div key={p.id} data-rail-item className="w-full">
275
+ <ProjectAvatar
276
+ label={label}
277
+ testId={`project-avatar-${p.id}`}
278
+ title={`${label} — ${p.path}`}
279
+ active={isActive(href)}
280
+ onClick={() => onSelect(href)}
281
+ />
282
+ </div>
283
+ );
284
+ })}
285
+ {overflow.length > 0 && (
286
+ <RailProjectMenu
287
+ projects={overflow}
288
+ label={`+${overflow.length}`}
289
+ tooltip={t("nav.more_projects", { count: overflow.length })}
290
+ header={t("nav.more_projects", { count: overflow.length })}
291
+ active={overflowHasActive}
292
+ testId="nav-projects-overflow"
293
+ onSelect={onSelect}
294
+ isActive={isActive}
295
+ />
296
+ )}
297
+ </>
298
+ )}
299
+
94
300
  <ProjectAvatar
95
- key={p.id}
96
- label={label}
97
- testId={`project-avatar-${p.id}`}
98
- title={`${label} — ${p.path}`}
99
- active={isActive(href)}
100
- onClick={() => onSelect(href)}
301
+ label={t("nav.add_project")}
302
+ isAdd
303
+ testId="nav-add-project"
304
+ icon={<Plus size={18} />}
305
+ active={false}
306
+ onClick={() => onSelect("/?action=add-project")}
307
+ title={t("nav.add_project")}
101
308
  />
102
- );
103
- })}
104
-
105
- <ProjectAvatar
106
- label={t("nav.add_project")}
107
- isAdd
108
- testId="nav-add-project"
109
- icon={<Plus size={18} />}
110
- active={false}
111
- onClick={() => onSelect("/?action=add-project")}
112
- title={t("nav.add_project")}
113
- />
309
+ </div>
310
+ </div>
114
311
 
115
- <div className="flex-1" />
116
312
  <ProjectAvatar
117
313
  label={t("nav.settings")}
118
314
  isSettings
@@ -130,7 +326,7 @@ export function ProjectSidebar({ onSelect, onOpenRoby }: Props) {
130
326
  rel="noopener noreferrer"
131
327
  data-testid="nav-docs"
132
328
  aria-label={t("settings_ui.documentation")}
133
- className="mt-1 flex size-10 items-center justify-center rounded-xl border border-border/60 bg-muted/30 text-muted-fg transition-colors hover:bg-accent hover:text-foreground"
329
+ className="flex size-10 items-center justify-center rounded-xl border border-border/60 bg-muted/30 text-muted-fg transition-colors hover:bg-accent hover:text-foreground"
134
330
  >
135
331
  <BookOpen size={18} />
136
332
  </a>
@@ -143,7 +339,7 @@ export function ProjectSidebar({ onSelect, onOpenRoby }: Props) {
143
339
  onClick={onOpenRoby}
144
340
  data-testid="nav-roby"
145
341
  aria-label={t("superagent.talk", { persona })}
146
- className="mt-1 flex size-10 items-center justify-center rounded-xl border border-border/60 bg-muted/30 text-muted-fg transition-colors hover:bg-accent hover:text-foreground"
342
+ className="flex size-10 items-center justify-center rounded-xl border border-border/60 bg-muted/30 text-muted-fg transition-colors hover:bg-accent hover:text-foreground"
147
343
  >
148
344
  <Bot size={18} />
149
345
  </button>
@@ -71,6 +71,10 @@ export const en = {
71
71
  settings: "Settings",
72
72
  project: "Project",
73
73
  add_project: "Add project",
74
+ all_projects: "All projects",
75
+ more_projects: "{count} more",
76
+ collapse_projects: "Hide projects",
77
+ expand_projects: "Show projects",
74
78
  modules: {
75
79
  voice: "Voices",
76
80
  desktop: "Desktop",
@@ -72,6 +72,10 @@ export const es = {
72
72
  settings: "Settings",
73
73
  project: "Proyecto",
74
74
  add_project: "Agregar proyecto",
75
+ all_projects: "Todos los proyectos",
76
+ more_projects: "{count} más",
77
+ collapse_projects: "Ocultar proyectos",
78
+ expand_projects: "Mostrar proyectos",
75
79
  modules: {
76
80
  voice: "Voces",
77
81
  desktop: "Escritorio",