@polderlabs/bizar 5.3.1 → 5.4.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.
Files changed (65) hide show
  1. package/bizar-dash/dist/assets/EnvVarsSection-DqvB8ZcJ.js +4 -0
  2. package/bizar-dash/dist/assets/EnvVarsSection-DqvB8ZcJ.js.map +1 -0
  3. package/bizar-dash/dist/assets/MobileChat-DRXP2ERQ.js +1 -0
  4. package/bizar-dash/dist/assets/{MobileChat-OidrxXR9.js.map → MobileChat-DRXP2ERQ.js.map} +1 -1
  5. package/bizar-dash/dist/assets/MobileSettings-CVW9VhEo.js +1 -0
  6. package/bizar-dash/dist/assets/MobileSettings-CVW9VhEo.js.map +1 -0
  7. package/bizar-dash/dist/assets/Toast-CcZVVN4i.js +1 -0
  8. package/bizar-dash/dist/assets/Toast-CcZVVN4i.js.map +1 -0
  9. package/bizar-dash/dist/assets/{icons-Bd8piSb2.js → icons-OMEGx5KA.js} +66 -61
  10. package/bizar-dash/dist/assets/icons-OMEGx5KA.js.map +1 -0
  11. package/bizar-dash/dist/assets/main-CMAGZouj.js +16 -0
  12. package/bizar-dash/dist/assets/main-CMAGZouj.js.map +1 -0
  13. package/bizar-dash/dist/assets/{main-3G6mOhiK.css → main-O2tZVdHm.css} +1 -1
  14. package/bizar-dash/dist/assets/mobile-XN2P7YfM.js +1 -0
  15. package/bizar-dash/dist/assets/mobile-XN2P7YfM.js.map +1 -0
  16. package/bizar-dash/dist/assets/{mobile-CsZQAswA.css → mobile-layout-D6V-7WnA.css} +1 -1
  17. package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js +2 -0
  18. package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js.map +1 -0
  19. package/bizar-dash/dist/assets/useSlashCommands-iyoW95UB.js +2 -0
  20. package/bizar-dash/dist/assets/useSlashCommands-iyoW95UB.js.map +1 -0
  21. package/bizar-dash/dist/index.html +8 -6
  22. package/bizar-dash/dist/mobile.html +4 -4
  23. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  24. package/bizar-dash/src/web/MobileApp.tsx +26 -19
  25. package/bizar-dash/src/web/components/MobileBottomNav.tsx +38 -0
  26. package/bizar-dash/src/web/components/MobileDrawer.tsx +68 -0
  27. package/bizar-dash/src/web/components/MobileHeader.tsx +50 -0
  28. package/bizar-dash/src/web/components/MobileLayout.tsx +63 -0
  29. package/bizar-dash/src/web/components/TaskCard.tsx +69 -0
  30. package/bizar-dash/src/web/components/TaskCreateSheet.tsx +76 -0
  31. package/bizar-dash/src/web/main.tsx +1 -0
  32. package/bizar-dash/src/web/mobile/MobileDoctor.tsx +80 -0
  33. package/bizar-dash/src/web/mobile/MobileEval.tsx +167 -0
  34. package/bizar-dash/src/web/mobile/MobileMarketplace.tsx +106 -0
  35. package/bizar-dash/src/web/mobile/MobileMemory.tsx +108 -0
  36. package/bizar-dash/src/web/mobile/MobileOverview.tsx +213 -0
  37. package/bizar-dash/src/web/mobile/MobilePlugins.tsx +71 -0
  38. package/bizar-dash/src/web/mobile/MobileSettings.tsx +235 -180
  39. package/bizar-dash/src/web/mobile/MobileTasks.tsx +94 -0
  40. package/bizar-dash/src/web/mobile.tsx +1 -0
  41. package/bizar-dash/src/web/styles/main.css +388 -0
  42. package/bizar-dash/src/web/styles/memory.css +45 -0
  43. package/bizar-dash/src/web/styles/mobile-layout.css +231 -0
  44. package/bizar-dash/src/web/styles/mobile.css +180 -0
  45. package/bizar-dash/src/web/styles/settings.css +110 -0
  46. package/bizar-dash/src/web/styles/tasks.css +305 -0
  47. package/bizar-dash/tests/mobile-chat.test.tsx +82 -0
  48. package/bizar-dash/tests/mobile-layout.test.tsx +296 -0
  49. package/bizar-dash/tests/mobile-misc.test.tsx +321 -0
  50. package/bizar-dash/tests/mobile-overview.test.tsx +214 -0
  51. package/bizar-dash/tests/mobile-settings.test.tsx +181 -0
  52. package/bizar-dash/tests/mobile-tasks.test.tsx +162 -0
  53. package/package.json +1 -1
  54. package/bizar-dash/dist/assets/MobileChat-OidrxXR9.js +0 -1
  55. package/bizar-dash/dist/assets/MobileSettings-B91gVFii.js +0 -1
  56. package/bizar-dash/dist/assets/MobileSettings-B91gVFii.js.map +0 -1
  57. package/bizar-dash/dist/assets/icons-Bd8piSb2.js.map +0 -1
  58. package/bizar-dash/dist/assets/main-6YUZY968.js +0 -19
  59. package/bizar-dash/dist/assets/main-6YUZY968.js.map +0 -1
  60. package/bizar-dash/dist/assets/mobile-CSsp30lr.js +0 -2
  61. package/bizar-dash/dist/assets/mobile-CSsp30lr.js.map +0 -1
  62. package/bizar-dash/dist/assets/mobile-DtwK7DOV.js +0 -1
  63. package/bizar-dash/dist/assets/mobile-DtwK7DOV.js.map +0 -1
  64. package/bizar-dash/dist/assets/useSlashCommands-DEkxEvOO.js +0 -2
  65. package/bizar-dash/dist/assets/useSlashCommands-DEkxEvOO.js.map +0 -1
@@ -1,196 +1,251 @@
1
- // src/web/mobile/MobileSettings.tsx — minimal mobile settings (no companion app, no agent config).
2
- // Drops: Companion App (qrcode.react chunk), Tailscale Serve, Agent Behavior, Notifications toggles.
3
- import { useEffect, useState } from 'react';
4
- import { Sun, Moon, Monitor } from 'lucide-react';
5
- import { api } from '../lib/api';
6
- import { applyTheme, applyThemeTokens, type Settings, type Snapshot, type ThemeName } from '../lib/types';
1
+ // src/web/mobile/MobileSettings.tsx — v5.4 mobile settings with full desktop parity.
2
+ // All 9 sections in an accordion layout with search.
3
+ import { useState } from 'react';
4
+ import {
5
+ Settings2, Brain, KeyRound, Cpu, RefreshCw, Globe, Info, ChevronRight,
6
+ } from 'lucide-react';
7
+ import { cn } from '../lib/utils';
8
+ import type { Settings, Snapshot } from '../lib/types';
9
+
10
+ // Sub-section components — each renders its section content inline.
11
+ import { GeneralSection } from '../views/settings/GeneralSection';
12
+ import { EnvVarsSection } from '../views/settings/EnvVarsSection';
13
+ import { MemorySection } from '../views/settings/MemorySection';
14
+ import { SystemLlmSection } from '../views/settings/SystemLlmSection';
15
+ import { UpdatesSection } from '../views/settings/UpdatesSection';
16
+ import { HeadroomSection } from '../views/settings/HeadroomSection';
17
+ import { TailscaleSettings } from '../components/TailscaleSettings';
18
+ import { ActivitySection } from '../views/settings/ActivitySection';
19
+
20
+ // Lazy-load heavy sections that pull in large dependencies.
21
+ import React from 'react';
7
22
 
8
23
  type Props = {
9
24
  settings: Settings;
10
- snapshot: Snapshot | null;
11
- onRefresh: () => Promise<void>;
25
+ /** Called when the user changes any top-level setting */
26
+ onSettingsChange?: <K extends keyof Settings>(key: K, value: Settings[K]) => void;
27
+ /** Debounced auto-save to API (key, value) */
28
+ autoSave?: (key: keyof Settings, value: Settings[keyof Settings]) => void;
29
+ /** Optional snapshot for counts / agent list */
30
+ snapshot?: Snapshot | null;
31
+ /** v5.3 compatibility — called after settings are saved (MobileApp.tsx passes refreshSnapshot) */
32
+ onRefresh?: () => Promise<void>;
12
33
  };
13
34
 
14
- const THEMES: { id: ThemeName; label: string; Icon: typeof Sun }[] = [
15
- { id: 'dark', label: 'Dark', Icon: Moon },
16
- { id: 'light', label: 'Light', Icon: Sun },
17
- { id: 'system', label: 'System', Icon: Monitor },
18
- ];
19
-
20
- const PRESET_ACCENTS = [
21
- { name: 'Purple', accent: '#8b5cf6' },
22
- { name: 'Blue', accent: '#3b82f6' },
23
- { name: 'Green', accent: '#10b981' },
24
- { name: 'Orange', accent: '#f97316' },
25
- { name: 'Red', accent: '#ef4444' },
26
- { name: 'Pink', accent: '#ec4899' },
27
- { name: 'Cyan', accent: '#06b6d4' },
28
- { name: 'Mono', accent: '#6b7280' },
29
- ];
30
-
31
- export function MobileSettings({ settings: initial, snapshot, onRefresh }: Props) {
32
- const [settings, setSettings] = useState<Settings>(initial);
33
- const [dirty, setDirty] = useState(false);
34
- const [saving, setSaving] = useState(false);
35
-
36
- useEffect(() => {
37
- setSettings(initial);
38
- setDirty(false);
39
- if (initial.theme) applyThemeTokens(initial.theme);
40
- }, [initial]);
41
-
42
- const patchTheme = (patch: Partial<Settings['theme']>) => {
43
- setSettings((cur) => {
44
- const next = { ...cur, theme: { ...cur.theme, ...patch } };
45
- applyTheme(next.theme);
46
- applyThemeTokens(next.theme);
47
- return next;
48
- });
49
- setDirty(true);
35
+ // ─── Section definitions ─────────────────────────────────────────────────────
36
+
37
+ function GeneralSettings({ settings, onSettingsChange, autoSave }: Props) {
38
+ const patchTop = <K extends keyof Settings>(key: K, value: Settings[K]) => {
39
+ onSettingsChange?.(key, value);
40
+ autoSave?.(key, value);
41
+ };
42
+ const patchUi = (patch: Partial<Settings['ui']>) => {
43
+ onSettingsChange?.('ui', { ...settings.ui, ...patch });
44
+ autoSave?.('ui', { ...settings.ui, ...patch });
50
45
  };
46
+ return (
47
+ <GeneralSection
48
+ settings={settings}
49
+ patchUi={patchUi}
50
+ patchTop={patchTop}
51
+ autoSave={autoSave}
52
+ />
53
+ );
54
+ }
51
55
 
52
- const onSave = async () => {
53
- setSaving(true);
54
- try {
55
- const r = await api.put<{ data: Settings }>('/settings', settings);
56
- setSettings(r.data);
57
- setDirty(false);
58
- applyTheme(r.data.theme);
59
- applyThemeTokens(r.data.theme);
60
- onRefresh();
61
- } catch {
62
- // best-effort
63
- } finally {
64
- setSaving(false);
65
- }
56
+ function ProvidersSettings({ settings, onSettingsChange, autoSave }: Props) {
57
+ // Providers are managed server-side; show a link to the desktop providers view.
58
+ // Mobile can't add/edit providers without more complex auth flow.
59
+ void settings; void onSettingsChange; void autoSave;
60
+ return (
61
+ <div style={{ fontSize: 13, color: 'var(--text-dim)', padding: '8px 0' }}>
62
+ <p style={{ marginBottom: 8 }}>
63
+ Provider management is available on desktop.
64
+ </p>
65
+ <a href="/?desktop=1#settings-providers" className="mobile-btn mobile-btn-secondary" style={{ display: 'inline-block', fontSize: 12, padding: '6px 12px' }}>
66
+ Open desktop to manage providers
67
+ </a>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ function EnvVarsSettings() {
73
+ return <EnvVarsSection />;
74
+ }
75
+
76
+ function MemorySettings() {
77
+ return <MemorySection />;
78
+ }
79
+
80
+ function SystemLlmSettings({ settings, onSettingsChange, autoSave }: Props) {
81
+ const patchTop = <K extends keyof Settings>(key: K, value: Settings[K]) => {
82
+ onSettingsChange?.(key, value);
83
+ autoSave?.(key, value);
66
84
  };
85
+ return <SystemLlmSection settings={settings} patchTop={patchTop} />;
86
+ }
67
87
 
88
+ function UpdatesSettings() {
89
+ return <UpdatesSection />;
90
+ }
91
+
92
+ function HeadroomSettingsSection({ settings }: { settings: Settings }) {
93
+ const [localSettings, setLocalSettings] = useState(settings);
94
+ const [dirty, setDirty] = useState(false);
68
95
  return (
69
- <div className="mobile-view">
70
- {dirty && (
71
- <div className="mobile-settings-save-bar">
72
- <span>Unsaved changes</span>
73
- <button type="button" className="mobile-btn" disabled={saving} onClick={onSave}>
74
- {saving ? 'Saving…' : 'Save'}
75
- </button>
76
- </div>
77
- )}
78
-
79
- {/* Theme */}
80
- <section className="mobile-section">
81
- <h3 className="mobile-section-title">Appearance</h3>
82
- <div className="mobile-card">
83
- <div className="mobile-setting-row">
84
- <span className="mobile-setting-label">Theme</span>
85
- <div className="mobile-theme-btns">
86
- {THEMES.map(({ id, label, Icon }) => (
87
- <button
88
- key={id}
89
- type="button"
90
- className={`mobile-theme-btn ${settings.theme.mode === id ? 'active' : ''}`}
91
- onClick={() => patchTheme({ mode: id })}
92
- >
93
- <Icon size={14} /> {label}
94
- </button>
95
- ))}
96
- </div>
97
- </div>
98
-
99
- <div className="mobile-setting-row" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
100
- <span className="mobile-setting-label">Accent color</span>
101
- <div className="mobile-accent-swatches">
102
- {PRESET_ACCENTS.map((p) => (
103
- <button
104
- key={p.name}
105
- type="button"
106
- className={`mobile-accent-swatch ${settings.theme.accent === p.accent ? 'active' : ''}`}
107
- style={{ background: p.accent }}
108
- onClick={() => patchTheme({ accent: p.accent })}
109
- title={p.name}
96
+ <HeadroomSection
97
+ settings={localSettings}
98
+ setSettings={setLocalSettings as React.Dispatch<React.SetStateAction<Settings>>}
99
+ setDirty={setDirty}
100
+ />
101
+ );
102
+ }
103
+
104
+ function TailscaleSettingsMobile() {
105
+ return <TailscaleSettings />;
106
+ }
107
+
108
+ function AboutSection({ settings }: { settings: Settings }) {
109
+ const about = settings.about || { version: '—', homepage: 'https://github.com/DrB0rk/BizarHarness', license: 'MIT' };
110
+ return <ActivitySection about={about} />;
111
+ }
112
+
113
+ // ─── Main component ──────────────────────────────────────────────────────────
114
+
115
+ export function MobileSettings({ settings, onSettingsChange, autoSave, snapshot }: Props) {
116
+ const [search, setSearch] = useState('');
117
+ const [expandedSection, setExpandedSection] = useState<string | null>(null);
118
+
119
+ const sections = [
120
+ {
121
+ id: 'general',
122
+ title: 'General',
123
+ icon: Settings2,
124
+ description: 'Theme, accent, layout',
125
+ component: <GeneralSettings settings={settings} onSettingsChange={onSettingsChange} autoSave={autoSave} />,
126
+ },
127
+ {
128
+ id: 'providers',
129
+ title: 'AI Providers',
130
+ icon: Brain,
131
+ description: `${snapshot?.providers?.length ?? 0} configured`,
132
+ component: <ProvidersSettings settings={settings} onSettingsChange={onSettingsChange} autoSave={autoSave} />,
133
+ },
134
+ {
135
+ id: 'env-vars',
136
+ title: 'Environment Variables',
137
+ icon: KeyRound,
138
+ description: 'API keys, secrets',
139
+ component: <EnvVarsSettings />,
140
+ },
141
+ {
142
+ id: 'memory',
143
+ title: 'Memory Vault',
144
+ icon: Brain,
145
+ description: 'Git remote, LightRAG config',
146
+ component: <MemorySettings />,
147
+ },
148
+ {
149
+ id: 'system-llm',
150
+ title: 'System LLM',
151
+ icon: Cpu,
152
+ description: settings.systemLlm?.model || 'not set',
153
+ component: <SystemLlmSettings settings={settings} onSettingsChange={onSettingsChange} autoSave={autoSave} />,
154
+ },
155
+ {
156
+ id: 'updates',
157
+ title: 'Updates',
158
+ icon: RefreshCw,
159
+ description: 'Check for updates',
160
+ component: <UpdatesSettings />,
161
+ },
162
+ {
163
+ id: 'headroom',
164
+ title: 'Headroom',
165
+ icon: Cpu,
166
+ description: settings.headroom?.enabled ? 'Enabled' : 'Disabled',
167
+ component: <HeadroomSettingsSection settings={settings} />,
168
+ },
169
+ {
170
+ id: 'tailscale',
171
+ title: 'Tailscale',
172
+ icon: Globe,
173
+ description: 'Tailscale serve & auth',
174
+ component: <TailscaleSettingsMobile />,
175
+ },
176
+ {
177
+ id: 'about',
178
+ title: 'About',
179
+ icon: Info,
180
+ description: `Bizar v${settings.about?.version || '—'}`,
181
+ component: <AboutSection settings={settings} />,
182
+ },
183
+ ];
184
+
185
+ const filtered = sections.filter(
186
+ (s) =>
187
+ !search ||
188
+ s.title.toLowerCase().includes(search.toLowerCase()) ||
189
+ s.description.toLowerCase().includes(search.toLowerCase()),
190
+ );
191
+
192
+ return (
193
+ <div className="mobile-settings">
194
+ <div className="mobile-settings-search">
195
+ <SearchIcon />
196
+ <input
197
+ value={search}
198
+ onChange={(e) => setSearch(e.target.value)}
199
+ placeholder="Search settings..."
200
+ aria-label="Search settings"
201
+ />
202
+ </div>
203
+
204
+ <div className="mobile-settings-list">
205
+ {filtered.map((s) => {
206
+ const Icon = s.icon;
207
+ const isExpanded = expandedSection === s.id;
208
+ return (
209
+ <div key={s.id} className={cn('mobile-settings-item', isExpanded && 'is-expanded')}>
210
+ <button
211
+ className="mobile-settings-item-head"
212
+ onClick={() => setExpandedSection(isExpanded ? null : s.id)}
213
+ aria-expanded={isExpanded}
214
+ aria-controls={`mobile-settings-body-${s.id}`}
215
+ >
216
+ <div className="mobile-settings-item-icon">
217
+ <Icon size={18} />
218
+ </div>
219
+ <div className="mobile-settings-item-text">
220
+ <div className="mobile-settings-item-title">{s.title}</div>
221
+ <div className="mobile-settings-item-desc">{s.description}</div>
222
+ </div>
223
+ <ChevronRight
224
+ size={16}
225
+ className={cn('mobile-settings-item-chevron', isExpanded && 'is-rotated')}
110
226
  />
111
- ))}
227
+ </button>
228
+ {isExpanded && (
229
+ <div id={`mobile-settings-body-${s.id}`} className="mobile-settings-item-body">
230
+ {s.component}
231
+ </div>
232
+ )}
112
233
  </div>
113
- </div>
114
-
115
- <div className="mobile-setting-row" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
116
- <span className="mobile-setting-label">Font size: {settings.theme.fontSize}px</span>
117
- <input
118
- type="range"
119
- min={12}
120
- max={20}
121
- value={settings.theme.fontSize}
122
- onChange={(e) => patchTheme({ fontSize: Number(e.target.value) })}
123
- style={{ width: '100%' }}
124
- />
125
- </div>
126
-
127
- <div className="mobile-setting-row">
128
- <span className="mobile-setting-label">Compact mode</span>
129
- <label className="mobile-toggle">
130
- <input
131
- type="checkbox"
132
- checked={settings.theme.compactMode}
133
- onChange={(e) => patchTheme({ compactMode: e.target.checked })}
134
- />
135
- <span className="mobile-toggle-slider" />
136
- </label>
137
- </div>
138
- <div className="mobile-setting-row">
139
- <span className="mobile-setting-label">Animations</span>
140
- <label className="mobile-toggle">
141
- <input
142
- type="checkbox"
143
- checked={settings.theme.animations}
144
- onChange={(e) => patchTheme({ animations: e.target.checked })}
145
- />
146
- <span className="mobile-toggle-slider" />
147
- </label>
148
- </div>
149
- </div>
150
- </section>
151
-
152
- {/* Chat defaults */}
153
- <section className="mobile-section">
154
- <h3 className="mobile-section-title">Chat Defaults</h3>
155
- <div className="mobile-card">
156
- <label className="mobile-field-label">Default Agent</label>
157
- <select
158
- className="mobile-input"
159
- value={settings.defaultAgent || 'odin'}
160
- onChange={(e) => setSettings((cur) => ({ ...cur, defaultAgent: e.target.value }))}
161
- >
162
- {(snapshot?.agents || []).map((a) => (
163
- <option key={a.name} value={a.name}>@{a.name}</option>
164
- ))}
165
- </select>
166
- </div>
167
- </section>
168
-
169
- {/* About */}
170
- <section className="mobile-section">
171
- <h3 className="mobile-section-title">About</h3>
172
- <div className="mobile-card">
173
- <div className="mobile-setting-row">
174
- <span className="mobile-setting-label">Version</span>
175
- <span className="mobile-setting-value mono">{settings.about?.version || '—'}</span>
176
- </div>
177
- <div className="mobile-setting-row">
178
- <span className="mobile-setting-label">Agents</span>
179
- <span className="mobile-setting-value">{snapshot?.agents?.length || 0}</span>
180
- </div>
181
- <div className="mobile-setting-row">
182
- <span className="mobile-setting-label">Tasks</span>
183
- <span className="mobile-setting-value">{snapshot?.tasks?.length || 0}</span>
184
- </div>
185
- </div>
186
- </section>
187
-
188
- {/* Switch to desktop */}
189
- <div className="mobile-view-footer">
190
- <a href="/?desktop=1" className="mobile-btn mobile-btn-secondary">
191
- Switch to Desktop
192
- </a>
234
+ );
235
+ })}
236
+ {filtered.length === 0 && (
237
+ <div className="mobile-settings-empty">No settings match "{search}"</div>
238
+ )}
193
239
  </div>
194
240
  </div>
195
241
  );
196
242
  }
243
+
244
+ function SearchIcon() {
245
+ return (
246
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
247
+ <circle cx="11" cy="11" r="8" />
248
+ <path d="m21 21-4.35-4.35" />
249
+ </svg>
250
+ );
251
+ }
@@ -0,0 +1,94 @@
1
+ // src/mobile/MobileTasks.tsx — filter-tab task list with search and FAB create sheet.
2
+ import { useState } from 'react';
3
+ import { Search, Plus } from 'lucide-react';
4
+ import { cn } from '../lib/utils';
5
+ import { TaskCard } from '../components/TaskCard';
6
+ import { TaskCreateSheet } from '../components/TaskCreateSheet';
7
+ import type { Snapshot } from '../lib/types';
8
+
9
+ type Filter = 'all' | 'todo' | 'doing' | 'done' | 'failed';
10
+
11
+ interface Props {
12
+ snapshot: Snapshot;
13
+ refreshSnapshot: () => Promise<void>;
14
+ userId?: string;
15
+ workspaceId?: string;
16
+ }
17
+
18
+ export function MobileTasks({ snapshot, refreshSnapshot, userId, workspaceId }: Props) {
19
+ const [filter, setFilter] = useState<Filter>('all');
20
+ const [showCreate, setShowCreate] = useState(false);
21
+ const [search, setSearch] = useState('');
22
+
23
+ const tasks = (snapshot?.tasks || []).filter((t) => {
24
+ if (filter === 'todo' && t.status !== 'queued' && t.status !== 'todo') return false;
25
+ if (filter === 'doing' && t.status !== 'doing') return false;
26
+ if (filter === 'done' && t.status !== 'done') return false;
27
+ if (filter === 'failed' && t.status !== 'failed' && t.status !== 'blocked') return false;
28
+ if (search && !(t.title || '').toLowerCase().includes(search.toLowerCase())) return false;
29
+ return true;
30
+ });
31
+
32
+ const counts: Record<Filter, number> = {
33
+ all: snapshot?.tasks?.length || 0,
34
+ todo: snapshot?.tasks?.filter((t) => t.status === 'queued' || t.status === 'todo').length || 0,
35
+ doing: snapshot?.tasks?.filter((t) => t.status === 'doing').length || 0,
36
+ done: snapshot?.tasks?.filter((t) => t.status === 'done').length || 0,
37
+ failed: snapshot?.tasks?.filter((t) => t.status === 'failed' || t.status === 'blocked').length || 0,
38
+ };
39
+
40
+ return (
41
+ <div className="mobile-tasks">
42
+ <div className="mobile-tasks-search">
43
+ <Search size={16} className="mobile-tasks-search-icon" />
44
+ <input
45
+ value={search}
46
+ onChange={(e) => setSearch(e.target.value)}
47
+ placeholder="Search tasks..."
48
+ className="mobile-tasks-search-input"
49
+ />
50
+ </div>
51
+
52
+ <div className="mobile-tasks-tabs">
53
+ {(['all', 'todo', 'doing', 'done', 'failed'] as Filter[]).map((f) => (
54
+ <button
55
+ key={f}
56
+ type="button"
57
+ className={cn('mobile-tasks-tab', filter === f && 'is-active')}
58
+ onClick={() => setFilter(f)}
59
+ >
60
+ {f.charAt(0).toUpperCase() + f.slice(1)}
61
+ <span className="mobile-tasks-count">{counts[f]}</span>
62
+ </button>
63
+ ))}
64
+ </div>
65
+
66
+ <div className="mobile-tasks-list">
67
+ {tasks.length === 0 ? (
68
+ <p className="mobile-empty">No tasks. Create one to get started.</p>
69
+ ) : (
70
+ tasks.map((t) => (
71
+ <TaskCard key={t.id} task={t} workspaceId={workspaceId} />
72
+ ))
73
+ )}
74
+ </div>
75
+
76
+ <button
77
+ type="button"
78
+ className="mobile-fab"
79
+ onClick={() => setShowCreate(true)}
80
+ aria-label="Create new task"
81
+ >
82
+ <Plus size={24} />
83
+ </button>
84
+
85
+ {showCreate && (
86
+ <TaskCreateSheet
87
+ workspaceId={workspaceId}
88
+ onClose={() => setShowCreate(false)}
89
+ onCreated={refreshSnapshot}
90
+ />
91
+ )}
92
+ </div>
93
+ );
94
+ }
@@ -3,6 +3,7 @@ import { StrictMode } from 'react';
3
3
  import { createRoot } from 'react-dom/client';
4
4
  import { MobileApp } from './MobileApp';
5
5
  import './styles/mobile.css';
6
+ import './styles/mobile-layout.css';
6
7
 
7
8
  const root = document.getElementById('root');
8
9
  if (!root) throw new Error('Root element #root not found');