Skip to content

Commit 30a35fc

Browse files
feat(frontend): compact settings navigation (#1278)
The settings page rendered every section in one narrow scrolling column, so finding a specific control became progressively harder as the surface grew. Keeping that document-style layout would also make each new setting increase the cost of navigating all the others. This adopts the shared kit-ui settings pattern already used by Middleman: localized Preferences, Data, and Connections navigation; keyword search; and one visible panel at a time. Panels stay mounted across category changes and empty searches so drafts survive exploration, while panel changes reset the internal scroller to the new heading. The route now provides the bounded flex host that the shared layout requires, keeping navigation and Full Resync fixed while long panels scroll independently. The kit-ui pin advances to the public commit that owns this responsive and accessible behavior, and the repository design guidance now reflects that component ownership. Reviewers should focus on the panel metadata boundary, the zero-result search sentinel that preserves mounted state, and the settings-specific scroll ownership in the app shell. <sup>generated by a clanker</sup> Co-authored-by: Marius van Niekerk <mariusvniekerk@users.noreply.github.com>
1 parent da67cde commit 30a35fc

27 files changed

Lines changed: 971 additions & 281 deletions

DESIGN.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,10 @@ Most shared controls come from the `@kenn-io/kit-ui` library (imported as
6565
`import { ... } from "@kenn-io/kit-ui"`): Button, Chip, CopyButton, EmptyState,
6666
FilterDropdown, FindBar, IconButton, KbdBadge, Modal, RangePicker,
6767
RefreshControl, SegmentedControl, Spinner, StatusBar, StatusDot,
68-
Table/TableHeaderCell, TextInput/SearchInput, Tooltip, TopBar, and Typeahead.
69-
Shared components take pre-translated strings as props — call `m.*()` at the
70-
call site (or in a thin app wrapper) and pass the result.
68+
SettingsLayout/SettingsSection, Table/TableHeaderCell, TextInput/SearchInput,
69+
Tooltip, TopBar, and Typeahead. Shared components take pre-translated strings as
70+
props — call `m.*()` at the call site (or in a thin app wrapper) and pass the
71+
result.
7172

7273
App-level glue that remains local:
7374

@@ -86,8 +87,14 @@ App-level glue that remains local:
8687
browser locale; the shared wrappers above do this.
8788
- `frontend/src/lib/components/content/SessionFindBar.svelte` wires kit-ui
8889
`FindBar` to the in-session search store.
89-
- `frontend/src/lib/components/settings/SettingsSection.svelte` owns settings
90-
section framing.
90+
- kit-ui `SettingsLayout` owns grouped settings navigation and scroll behavior;
91+
kit-ui `SettingsSection` owns settings section framing.
92+
`SettingsPage.svelte` intentionally integrates with the pinned layout's
93+
`.kit-settings__nav`, `.kit-settings__panel`, and `.kit-settings__scroll`
94+
elements to hide zero-result content and reset panel scroll. Treat these
95+
selectors as a version-pinned integration contract: kit-ui dependency bumps
96+
that touch settings must pass the settings browser coverage in CI before
97+
adoption.
9198

9299
Relative date ranges follow kit-ui semantics: "Last N days" spans N calendar
93100
days inclusive of today. `presetRange()` (dateRangeSelector.ts) and

docs/screenshots/tests/screenshots.spec.ts

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,19 +1053,40 @@ test.describe('Settings', () => {
10531053
await page.waitForTimeout(500);
10541054
}
10551055

1056+
async function openSettingsPanel(
1057+
page: Page,
1058+
navigationLabel: string,
1059+
headingLabel: string = navigationLabel
1060+
) {
1061+
const navigation = page.getByRole('navigation', {
1062+
name: 'Settings',
1063+
});
1064+
await navigation.getByRole('button', {
1065+
name: navigationLabel,
1066+
}).click();
1067+
1068+
const heading = page.getByRole('heading', {
1069+
level: 3,
1070+
name: headingLabel,
1071+
exact: true,
1072+
});
1073+
const section = page.locator('section').filter({ has: heading });
1074+
await expect(section).toBeVisible({ timeout: 5_000 });
1075+
return section;
1076+
}
1077+
10561078
test('settings page', async ({ page }) => {
10571079
await openSettings(page);
10581080
await snap(page, 'settings');
10591081
});
10601082

10611083
test('settings remote access section', async ({ page }) => {
10621084
await openSettings(page);
1063-
1064-
// Find the settings-section that contains "Remote Access"
1065-
const remoteSection = page.locator(
1066-
'.settings-section:has(.section-title:text("Remote Access"))'
1085+
const remoteSection = await openSettingsPanel(
1086+
page,
1087+
'Remote access',
1088+
'Remote Access'
10671089
);
1068-
await expect(remoteSection).toBeVisible({ timeout: 5_000 });
10691090
await remoteSection.scrollIntoViewIfNeeded();
10701091
await page.waitForTimeout(500);
10711092

@@ -1086,8 +1107,11 @@ test.describe('Settings', () => {
10861107
build_id: 38,
10871108
dimension: 256,
10881109
done,
1110+
estimate_ready: true,
1111+
eta_milliseconds: 40_000,
10891112
model: 'qwen3-embedding:0.6b',
10901113
phase: 'embedding',
1114+
rate_per_second: 10,
10911115
running: true,
10921116
started_at: startedAt,
10931117
total: 1000,
@@ -1118,10 +1142,10 @@ test.describe('Settings', () => {
11181142
);
11191143

11201144
await openSettings(page);
1121-
const embeddingsSection = page.locator(
1122-
'.settings-section:has(.section-title:text("Embeddings"))'
1145+
const embeddingsSection = await openSettingsPanel(
1146+
page,
1147+
'Embeddings'
11231148
);
1124-
await expect(embeddingsSection).toBeVisible({ timeout: 5_000 });
11251149
await embeddingsSection.scrollIntoViewIfNeeded();
11261150
await expect(
11271151
embeddingsSection.getByRole('progressbar', {
@@ -1137,23 +1161,17 @@ test.describe('Settings', () => {
11371161

11381162
test('worktree project mappings section', async ({ page }) => {
11391163
await openSettings(page);
1140-
1141-
// SettingsSection renders a heading with the title prop;
1142-
// match the section that contains the "Worktree mappings"
1143-
// header text, the same pattern used for Remote Access above.
1144-
const worktreeSection = page.locator(
1145-
'.settings-section:has-text("Worktree mappings")'
1164+
const worktreeSection = await openSettingsPanel(
1165+
page,
1166+
'Worktree mappings'
11461167
);
1147-
await expect(worktreeSection.first()).toBeVisible({
1148-
timeout: 5_000,
1149-
});
1150-
await worktreeSection.first().scrollIntoViewIfNeeded();
1151-
const mappingPath = worktreeSection.first().getByRole('textbox').first();
1168+
await worktreeSection.scrollIntoViewIfNeeded();
1169+
const mappingPath = worktreeSection.getByRole('textbox').first();
11521170
await mappingPath.fill('~/code/project.worktrees');
11531171
await expect(mappingPath).toHaveValue('~/code/project.worktrees');
11541172
await page.waitForTimeout(500);
11551173

1156-
await snapEl(worktreeSection.first(), 'worktree-mappings');
1174+
await snapEl(worktreeSection, 'worktree-mappings');
11571175
});
11581176
});
11591177

frontend/e2e/insights-quality.spec.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,11 @@ test.describe("Insights quality rollout", () => {
277277
name: "Generated Insights Archive",
278278
});
279279
await archive.getByTitle("Select template").click();
280-
await archive.getByRole("option", { name: "Model and Cost" }).click();
280+
const templateFilter = archive.getByRole("combobox", {
281+
name: "Filter templates...",
282+
});
283+
await templateFilter.fill("Model and Cost");
284+
await templateFilter.press("Enter");
281285
await archive.getByRole("button", { name: "Generate" }).click();
282286

283287
await expect(

frontend/e2e/session-list.spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,10 @@ test.describe("Session list", () => {
150150
const expectedTo = selectedUrl.searchParams.get("date_to");
151151

152152
await page.getByRole("button", { name: "Settings" }).click();
153+
await page
154+
.getByRole("navigation", { name: "Settings" })
155+
.locator("button", { hasText: "Date ranges" })
156+
.click();
153157
await page
154158
.getByRole("switch", { name: "Link date ranges across pages" })
155159
.check();
@@ -205,6 +209,10 @@ test.describe("Session list", () => {
205209
await page.locator(".kit-date-range-picker__trigger").click();
206210
await page.getByRole("button", { name: "90d", exact: true }).click();
207211
await page.getByRole("button", { name: "Settings" }).click();
212+
await page
213+
.getByRole("navigation", { name: "Settings" })
214+
.locator("button", { hasText: "Date ranges" })
215+
.click();
208216
await page
209217
.getByRole("switch", { name: "Link date ranges across pages" })
210218
.check();
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { expect, test, type Page, type Response } from "@playwright/test";
2+
3+
async function openSettledSettings(page: Page) {
4+
const responses: Response[] = [];
5+
const settingsLoaded = new Promise<void>((resolve) => {
6+
const onResponse = (response: Response) => {
7+
if (new URL(response.url()).pathname !== "/api/v1/settings") return;
8+
responses.push(response);
9+
if (responses.length < 2) return;
10+
page.off("response", onResponse);
11+
void Promise.all(responses.map((item) => item.finished())).then(() => {
12+
resolve();
13+
});
14+
};
15+
page.on("response", onResponse);
16+
});
17+
18+
await page.goto("/settings");
19+
await settingsLoaded;
20+
await page.evaluate(
21+
() => new Promise<void>((resolve) => {
22+
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
23+
}),
24+
);
25+
await expect(page.locator(".settings-loading")).toHaveCount(0);
26+
}
27+
28+
test.describe("Settings layout", () => {
29+
test("keeps navigation and actions fixed while panel content scrolls", async ({ page }) => {
30+
await page.setViewportSize({ width: 1280, height: 800 });
31+
await page.goto("/settings");
32+
33+
const nav = page.getByRole("navigation", { name: "Settings" });
34+
await expect(nav).toBeVisible();
35+
36+
const host = page.locator(".settings-page-host");
37+
await expect
38+
.poll(() => host.evaluate((element) => getComputedStyle(element).overflow))
39+
.toBe("hidden");
40+
await expect
41+
.poll(() => host.evaluate((element) => element.scrollHeight))
42+
.toBe(await host.evaluate((element) => element.clientHeight));
43+
44+
await nav.locator("button", { hasText: "Agent Directories" }).click();
45+
await expect(page.getByRole("heading", { name: "Agent Directories" })).toBeVisible();
46+
47+
const scroller = page.locator(".kit-settings__scroll");
48+
await expect
49+
.poll(() => scroller.evaluate((element) => element.scrollHeight > element.clientHeight))
50+
.toBe(true);
51+
await scroller.evaluate((element) => {
52+
element.scrollTop = element.scrollHeight;
53+
});
54+
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
55+
await expect(page.getByRole("button", { name: "Full Resync" })).toBeVisible();
56+
57+
await nav.locator("button", { hasText: "Terminal" }).click();
58+
await expect(page.getByRole("heading", { name: "Terminal" })).toBeVisible();
59+
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBe(0);
60+
});
61+
62+
test("keeps settings operable at the narrow breakpoint", async ({ page }) => {
63+
await page.setViewportSize({ width: 700, height: 800 });
64+
await page.goto("/settings");
65+
66+
const search = page.getByRole("searchbox", { name: "Search settings" });
67+
await expect(search).toBeVisible();
68+
69+
const nav = page.getByRole("navigation", { name: "Settings" });
70+
await nav.locator("button", { hasText: "Terminal" }).click();
71+
await expect(page.getByRole("heading", { name: "Terminal" })).toBeVisible();
72+
await expect(page.getByRole("button", { name: "Full Resync" })).toBeVisible();
73+
await expect
74+
.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth))
75+
.toBe(true);
76+
});
77+
78+
test("hides an unmatched panel without discarding its draft", async ({ page }) => {
79+
// App and SettingsPage both start an initial settings read. Wait for both
80+
// before editing so a late response cannot reset the unsaved draft.
81+
await openSettledSettings(page);
82+
83+
const nav = page.getByRole("navigation", { name: "Settings" });
84+
await nav.locator("button", { hasText: "Terminal" }).click();
85+
await page.getByRole("radio", { name: "Custom", exact: true }).click();
86+
87+
const binary = page.getByLabel("Terminal binary");
88+
await binary.fill("/usr/bin/kitty");
89+
90+
const search = page.getByRole("searchbox", { name: "Search settings" });
91+
await search.fill("no such setting");
92+
93+
await expect(page.getByText("No matching settings", { exact: true })).toBeVisible();
94+
await expect(page.getByRole("heading", { name: "Terminal" })).toHaveCount(0);
95+
await expect(page.locator("#terminal-bin")).toBeHidden();
96+
97+
await search.fill("");
98+
99+
await expect(page.getByRole("heading", { name: "Terminal" })).toBeVisible();
100+
await expect(binary).toHaveValue("/usr/bin/kitty");
101+
});
102+
});

frontend/e2e/usage.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,10 @@ test.describe("Usage page", () => {
213213
await expect(page).toHaveURL(/window_days=90/);
214214

215215
await page.getByRole("button", { name: "Settings" }).click();
216+
await page
217+
.getByRole("navigation", { name: "Settings" })
218+
.locator("button", { hasText: "Date ranges" })
219+
.click();
216220
await page
217221
.getByRole("switch", { name: "Link date ranges across pages" })
218222
.check();

frontend/messages/en.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,6 +865,25 @@
865865
"usage_more_than_uncached": "{cost} more than uncached",
866866
"usage_top_sessions_by_cost": "Top Sessions by Cost",
867867
"settings_title": "Settings",
868+
"settings_group_preferences": "Preferences",
869+
"settings_group_data": "Data",
870+
"settings_group_connections": "Connections",
871+
"settings_nav_github": "GitHub",
872+
"settings_nav_remote_access": "Remote access",
873+
"settings_search_placeholder": "Search settings...",
874+
"settings_search_aria": "Search settings",
875+
"settings_search_empty": "No matching settings",
876+
"settings_search_clear": "Clear search",
877+
"settings_search_result": "Showing {category}",
878+
"settings_search_keywords_appearance": "theme contrast layout text font blocks",
879+
"settings_search_keywords_language": "language locale translation",
880+
"settings_search_keywords_date_ranges": "date range linked pages",
881+
"settings_search_keywords_terminal": "terminal resume launch binary arguments clipboard",
882+
"settings_search_keywords_agent_directories": "agent directories paths scan sessions",
883+
"settings_search_keywords_worktree_mappings": "worktree mappings projects paths layouts",
884+
"settings_search_keywords_embeddings": "embeddings semantic search vectors index generations",
885+
"settings_search_keywords_github": "GitHub Gist token publish integration",
886+
"settings_search_keywords_remote_access": "remote server connection auth token network",
868887
"settings_loading": "Loading settings...",
869888
"settings_date_ranges_title": "Date ranges",
870889
"settings_date_ranges_description": "Share date selections across Sessions, Usage, Activity, Trends, and Insights.",

frontend/messages/fr.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,6 +865,25 @@
865865
"usage_more_than_uncached": "{cost} de plus que le hors cache",
866866
"usage_top_sessions_by_cost": "Sessions les plus coûteuses",
867867
"settings_title": "Paramètres",
868+
"settings_group_preferences": "Préférences",
869+
"settings_group_data": "Données",
870+
"settings_group_connections": "Connexions",
871+
"settings_nav_github": "GitHub",
872+
"settings_nav_remote_access": "Accès distant",
873+
"settings_search_placeholder": "Rechercher dans les paramètres...",
874+
"settings_search_aria": "Rechercher dans les paramètres",
875+
"settings_search_empty": "Aucun paramètre correspondant",
876+
"settings_search_clear": "Effacer la recherche",
877+
"settings_search_result": "Affichage de {category}",
878+
"settings_search_keywords_appearance": "thème contraste disposition texte police blocs theme contrast layout text font blocks",
879+
"settings_search_keywords_language": "langue paramètres régionaux traduction language locale translation",
880+
"settings_search_keywords_date_ranges": "date plage pages liées date range linked pages",
881+
"settings_search_keywords_terminal": "terminal reprise lancement exécutable arguments presse-papiers terminal resume launch binary clipboard",
882+
"settings_search_keywords_agent_directories": "agent dossiers chemins analyse sessions agent directories paths scan",
883+
"settings_search_keywords_worktree_mappings": "arbres de travail associations projets chemins dispositions worktree mappings projects paths layouts",
884+
"settings_search_keywords_embeddings": "plongements recherche sémantique vecteurs index générations embeddings semantic search vectors generations",
885+
"settings_search_keywords_github": "GitHub Gist jeton publication intégration token publish",
886+
"settings_search_keywords_remote_access": "distant serveur connexion authentification jeton réseau remote server connection auth token network",
868887
"settings_loading": "Chargement des paramètres...",
869888
"settings_date_ranges_title": "Plages de dates",
870889
"settings_date_ranges_description": "Partager la sélection de dates entre Sessions, Consommation, Activité, Tendances et Analyses.",

frontend/messages/ko.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,25 @@
840840
"usage_more_than_uncached": "캐시하지 않았을 때보다 {cost} 더 많음",
841841
"usage_top_sessions_by_cost": "비용 상위 세션",
842842
"settings_title": "설정",
843+
"settings_group_preferences": "환경설정",
844+
"settings_group_data": "데이터",
845+
"settings_group_connections": "연결",
846+
"settings_nav_github": "GitHub",
847+
"settings_nav_remote_access": "원격 액세스",
848+
"settings_search_placeholder": "설정 검색...",
849+
"settings_search_aria": "설정 검색",
850+
"settings_search_empty": "일치하는 설정 없음",
851+
"settings_search_clear": "검색 지우기",
852+
"settings_search_result": "{category} 표시 중",
853+
"settings_search_keywords_appearance": "테마 대비 레이아웃 텍스트 글꼴 블록 theme contrast layout text font blocks",
854+
"settings_search_keywords_language": "언어 로캘 번역 language locale translation",
855+
"settings_search_keywords_date_ranges": "날짜 범위 연결 페이지 date range linked pages",
856+
"settings_search_keywords_terminal": "터미널 재개 실행 바이너리 인수 클립보드 terminal resume launch binary arguments clipboard",
857+
"settings_search_keywords_agent_directories": "에이전트 디렉터리 경로 스캔 세션 agent directories paths scan sessions",
858+
"settings_search_keywords_worktree_mappings": "워크트리 매핑 프로젝트 경로 레이아웃 worktree mappings projects paths layouts",
859+
"settings_search_keywords_embeddings": "임베딩 시맨틱 검색 벡터 인덱스 세대 embeddings semantic search vectors index generations",
860+
"settings_search_keywords_github": "GitHub Gist 토큰 게시 통합 token publish integration",
861+
"settings_search_keywords_remote_access": "원격 서버 연결 인증 토큰 네트워크 remote server connection auth token network",
843862
"settings_loading": "설정을 불러오는 중...",
844863
"settings_date_ranges_title": "날짜 범위",
845864
"settings_date_ranges_description": "세션, 사용량, 활동, 트렌드, 인사이트 페이지에서 선택한 날짜 범위를 공유합니다.",

frontend/messages/zh-CN.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,25 @@
838838
"usage_more_than_uncached": "比未缓存多 {cost}",
839839
"usage_top_sessions_by_cost": "按成本排序的热门会话",
840840
"settings_title": "设置",
841+
"settings_group_preferences": "偏好设置",
842+
"settings_group_data": "数据",
843+
"settings_group_connections": "连接",
844+
"settings_nav_github": "GitHub",
845+
"settings_nav_remote_access": "远程访问",
846+
"settings_search_placeholder": "搜索设置...",
847+
"settings_search_aria": "搜索设置",
848+
"settings_search_empty": "没有匹配的设置",
849+
"settings_search_clear": "清除搜索",
850+
"settings_search_result": "显示:{category}",
851+
"settings_search_keywords_appearance": "主题 对比度 布局 文本 字体 区块 theme contrast layout text font blocks",
852+
"settings_search_keywords_language": "语言 区域设置 翻译 language locale translation",
853+
"settings_search_keywords_date_ranges": "日期 范围 关联 页面 date range linked pages",
854+
"settings_search_keywords_terminal": "终端 恢复 启动 可执行文件 参数 剪贴板 terminal resume launch binary arguments clipboard",
855+
"settings_search_keywords_agent_directories": "代理 目录 路径 扫描 会话 agent directories paths scan sessions",
856+
"settings_search_keywords_worktree_mappings": "工作树 映射 项目 路径 布局 worktree mappings projects paths layouts",
857+
"settings_search_keywords_embeddings": "嵌入 语义 搜索 向量 索引 代 embeddings semantic search vectors index generations",
858+
"settings_search_keywords_github": "GitHub Gist 令牌 发布 集成 token publish integration",
859+
"settings_search_keywords_remote_access": "远程 服务器 连接 鉴权 令牌 网络 remote server connection auth token network",
841860
"settings_loading": "正在加载设置...",
842861
"settings_date_ranges_title": "日期范围",
843862
"settings_date_ranges_description": "在会话、用量、活动、趋势和洞察页面之间共享日期选择。",

0 commit comments

Comments
 (0)