Skip to content

Commit 9ebdbae

Browse files
committed
feat(pwa): bring pages, search, and command palette to the mobile shell (WI-1320)
- /m/pages lists every visible page across the user's workspaces with a client-side title filter; /m/pages/:ws/:page renders markdown with a breadcrumb, sub-page navigation, and hash-guarded editing gated on effective page permissions - Mobile search fans page-title search out per workspace alongside work items - New bottom-sheet command palette reuses the desktop command providers and ranking (extracted to commands/rank.js); a mobile navigation provider offers only /m destinations and the executor rewrites known desktop URLs so the phone never leaves the shell - Pages tab joins the bottom nav; a palette trigger sits in every tab header
1 parent 0084315 commit 9ebdbae

19 files changed

Lines changed: 1398 additions & 54 deletions

frontend/src/lib/commands/context.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* activeTimer: any,
1515
* t: (key:string, fallback?:any) => string,
1616
* query: string,
17+
* pageResults?: any[],
1718
* }} input
1819
*/
1920
export function buildContext({
@@ -27,6 +28,7 @@ export function buildContext({
2728
activeTimer,
2829
t,
2930
query,
31+
pageResults,
3032
}) {
3133
const workspaceId = route?.params?.id ? Number(route.params.id) : null;
3234
const collectionId = route?.params?.collectionId ?? null;
@@ -44,6 +46,7 @@ export function buildContext({
4446
itemId,
4547
item: null,
4648
workItems: workItems || [],
49+
pageResults: pageResults || [],
4750
activeTimer: activeTimer || null,
4851
t,
4952
query: query || '',
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { aiStore } from '../../stores';
2+
import { BUCKET } from '../buckets.js';
3+
import { createCommand } from '../types.js';
4+
5+
/**
6+
* Mobile shell destinations. Only surfaces that exist under /m are offered —
7+
* desktop-only modules (boards, admin, time reports) have no phone surface
8+
* yet and would strand the user in desktop chrome.
9+
*/
10+
export function mobileNavigationProvider(_ctx) {
11+
const out = [
12+
createCommand({
13+
id: 'm-my-work',
14+
label: 'My Work',
15+
description: 'Assigned, watched, recent',
16+
bucket: BUCKET.GLOBAL_NAVIGATION,
17+
keywords: ['my work', 'home', 'items', 'assigned', 'start'],
18+
url: '/m',
19+
}),
20+
createCommand({
21+
id: 'm-personal',
22+
label: 'Personal',
23+
description: 'Personal tasks',
24+
bucket: BUCKET.GLOBAL_NAVIGATION,
25+
keywords: ['personal', 'tasks', 'todo'],
26+
url: '/m/personal',
27+
}),
28+
createCommand({
29+
id: 'm-pages',
30+
label: 'Pages',
31+
description: 'Workspace knowledge base',
32+
bucket: BUCKET.GLOBAL_NAVIGATION,
33+
keywords: ['pages', 'wiki', 'knowledge', 'docs', 'notes'],
34+
url: '/m/pages',
35+
}),
36+
createCommand({
37+
id: 'm-timer',
38+
label: 'Timer',
39+
description: 'Time tracking',
40+
bucket: BUCKET.GLOBAL_NAVIGATION,
41+
keywords: ['timer', 'time', 'tracking', 'worklog'],
42+
url: '/m/timer',
43+
}),
44+
createCommand({
45+
id: 'm-notifications',
46+
label: 'Alerts',
47+
description: 'Notifications',
48+
bucket: BUCKET.GLOBAL_NAVIGATION,
49+
keywords: ['alerts', 'notifications', 'inbox', 'unread'],
50+
url: '/m/notifications',
51+
}),
52+
createCommand({
53+
id: 'm-search',
54+
label: 'Search',
55+
description: 'Find items and pages',
56+
bucket: BUCKET.GLOBAL_NAVIGATION,
57+
keywords: ['search', 'find', 'look'],
58+
url: '/m/search',
59+
}),
60+
];
61+
62+
if (aiStore.chatAvailable) {
63+
out.push(
64+
createCommand({
65+
id: 'm-chat',
66+
label: 'Assistant',
67+
description: 'AI chat',
68+
bucket: BUCKET.GLOBAL_NAVIGATION,
69+
keywords: ['assistant', 'ai', 'chat', 'ask'],
70+
url: '/m/chat',
71+
})
72+
);
73+
}
74+
75+
return out;
76+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { BUCKET } from '../buckets.js';
2+
import { createCommand } from '../types.js';
3+
4+
/**
5+
* Knowledge-page search results. The caller (desktop palette or mobile sheet)
6+
* debounces the API fan-out and writes tagged results into ctx.pageResults;
7+
* each row links to the page reader (mobile URLs are rewritten by the
8+
* mobile executor).
9+
*/
10+
export function pageSearchProvider(ctx) {
11+
const { pageResults } = ctx;
12+
if (!pageResults?.length) return [];
13+
14+
return pageResults.map((p) =>
15+
createCommand({
16+
id: `goto-page-${p.workspace_id}-${p.id}`,
17+
label: p.title ?? '',
18+
description: p.workspace_name || '',
19+
bucket: BUCKET.SEARCH_RESULTS,
20+
keywords: [p.title?.toLowerCase(), p.workspace_name?.toLowerCase()].filter(Boolean),
21+
url: `/m/pages/${p.workspace_id}/${p.id}`,
22+
})
23+
);
24+
}

frontend/src/lib/commands/rank.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { PER_BUCKET_CAP, TOTAL_CAP } from './buckets.js';
2+
import { compareCommands, scoreCommand } from './score.js';
3+
import { deriveLegacyBucket } from './types.js';
4+
5+
/**
6+
* Score commands against the query, sort by (bucket, score, insertion), and
7+
* cap per-bucket and overall. Providers set `bucket` explicitly;
8+
* deriveLegacyBucket is the safety net for commands flowing in through
9+
* makeExternalProvider that haven't been updated yet. Shared by the desktop
10+
* palette and the mobile sheet so both surfaces rank identically.
11+
*
12+
* @param {string} query
13+
* @param {any[]} commandsList
14+
* @returns {any[]}
15+
*/
16+
export function rankCommands(query, commandsList) {
17+
const annotated = commandsList.map((cmd, i) => {
18+
const label = cmd.label ?? '';
19+
const description = cmd.description ?? '';
20+
const keywords = cmd.keywords ?? [];
21+
const score = query.trim() ? scoreCommand(query, { label, description, keywords }) : 1;
22+
return {
23+
...cmd,
24+
bucket: cmd.bucket || deriveLegacyBucket(cmd),
25+
_score: score,
26+
_seq: cmd._seq ?? i,
27+
};
28+
});
29+
30+
const filtered = query.trim() ? annotated.filter((c) => c._score > 0) : annotated;
31+
filtered.sort(compareCommands(query));
32+
33+
const counts = new Map();
34+
const out = [];
35+
for (const c of filtered) {
36+
if (out.length >= TOTAL_CAP) break;
37+
const n = counts.get(c.bucket) || 0;
38+
if (n >= PER_BUCKET_CAP) continue;
39+
counts.set(c.bucket, n + 1);
40+
out.push(c);
41+
}
42+
return out;
43+
}

frontend/src/lib/layout/CommandPalette.svelte

Lines changed: 3 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111
import { t } from '../stores/i18n.svelte.js';
1212
import ModalBackdrop from '../components/ModalBackdrop.svelte';
1313
14-
import { scoreCommand, compareCommands } from '../commands/score.js';
15-
import { BUCKET, BUCKET_LABELS, PER_BUCKET_CAP, TOTAL_CAP } from '../commands/buckets.js';
16-
import { deriveLegacyBucket } from '../commands/types.js';
14+
import { scoreCommand } from '../commands/score.js';
15+
import { BUCKET, BUCKET_LABELS } from '../commands/buckets.js';
16+
import { rankCommands } from '../commands/rank.js';
1717
import { buildContext } from '../commands/context.js';
1818
import { buildCommands } from '../commands/buildCommands.js';
1919
import { executeCommand as runCommand } from '../commands/executor.js';
@@ -137,39 +137,6 @@
137137
PROVIDERS,
138138
));
139139
140-
// Score, sort by (bucket, score, insertion), cap per-bucket and overall.
141-
// Providers set `bucket` explicitly; deriveLegacyBucket is the safety net
142-
// for commands flowing in through makeExternalProvider that haven't been
143-
// updated yet.
144-
function rankCommands(query, commandsList) {
145-
const annotated = commandsList.map((cmd, i) => {
146-
const label = cmd.label ?? '';
147-
const description = cmd.description ?? '';
148-
const keywords = cmd.keywords ?? [];
149-
const score = query.trim() ? scoreCommand(query, { label, description, keywords }) : 1;
150-
return {
151-
...cmd,
152-
bucket: cmd.bucket || deriveLegacyBucket(cmd),
153-
_score: score,
154-
_seq: cmd._seq ?? i,
155-
};
156-
});
157-
158-
const filtered = query.trim() ? annotated.filter((c) => c._score > 0) : annotated;
159-
filtered.sort(compareCommands(query));
160-
161-
const counts = new Map();
162-
const out = [];
163-
for (const c of filtered) {
164-
if (out.length >= TOTAL_CAP) break;
165-
const n = counts.get(c.bucket) || 0;
166-
if (n >= PER_BUCKET_CAP) continue;
167-
counts.set(c.bucket, n + 1);
168-
out.push(c);
169-
}
170-
return out;
171-
}
172-
173140
// Recently-viewed items mapped to command-shaped entries so the existing
174141
// render loop + keyboard handling drive navigation. No per-bucket cap is
175142
// applied here — the backend already bounds the list to the last 20.

0 commit comments

Comments
 (0)