Files
proof-of-work/proof-of-work/app/main/navigation.tsx
T
Keysat 7a62690a4a v1.1.0:4 — multi-config AI, background generation, ollama auto-detect, system prompt overhaul
User-feedback-driven release after testing v1.1.0:3. Nine themes:

1. Multi-config persistence
   - New AIConfigProfile table (per-user). Save N configs, toggle one
     active. Switching providers no longer wipes the previous setup.
   - UserPreferences gains activeAIConfigId; legacy single-config
     columns are mirrored from the active profile so existing reads
     keep working without conditional logic.
   - Idempotent boot migration lifts any existing single-config row
     into a default profile.

2. Ollama auto-detect
   - The "Add config" form probes /api/tags on the StartOS internal
     addresses (ollama.startos / ollama.embassy on :11434). If
     reachable: URL pre-fills, model field becomes a dropdown of
     installed models. Fixes the copy-paste UX.

3. Curated model dropdowns for major providers
   - Claude: Opus 4.7, Sonnet 4.6 (1M ctx), Haiku 4.5
   - OpenAI: GPT-5.5, 5.4, 5.4-mini, 5.4-nano
   - Gemini: 3.1-pro-preview, 2.5-pro, 2.5-flash, etc.
   - "Other (type your own)" stays for niche models.
   - Fixes "I tried gemini-3.0-pro and got 404."

4. Background generation
   - lib/ai/generationRunner.ts: detached runner with in-memory
     pub/sub bus. POST /api/ai/generate kicks it off and returns
     immediately. SSE stream attaches by id. The runner survives
     request cancellation; navigating away no longer kills it.
   - New AIGeneration columns: progressText (in-flight stream),
     durationMs (final wall-clock).
   - Generate UI shows a banner explaining background-safety.
   - History detail page polls progress + renders partial JSON
     live for cross-process resume (page refresh, new tab).

5. System prompt overhaul
   - lib/ai/systemPromptBase.ts: structural contract prepended to
     every template. Forces JSON-only output, library-exerciseId
     usage (kills "exerciseId doesn't belong to this user" errors),
     and per-resistance-exercise suggestedWeight (with-history vs
     without-history variants).
   - aiExerciseSchema + ProgramExercise gain suggestedWeight +
     suggestedWeightUnit. Starting a workout from a ProgramDay
     pre-populates SetLog.weight from the suggestion.

6. Test connection improvements
   - Latency in seconds (was ms — confusing for slow Ollama).
   - Stale "✓ Connected" clears on form change.
   - Per-config Test (no need to activate first).
   - Generous maxOutputTokens for thinking models.
   - Gemini surfaces finishReason on empty response (e.g. "blocked
     by safety filter") instead of generic "empty response."
   - Test endpoint accepts a draft body so you can verify before
     saving + before activating.

7. History detail view
   - Click row → full program tree + exact prompts sent. Apply from
     here without re-generating. Pending rows poll for progress.

8. Sidebar sub-navigation
   - AI: Generate / History / Templates
   - Settings: General / Password / Sessions / AI integration /
     Export / Instance (admin) / Danger zone, with anchor scroll.

9. API key UX
   - "Key saved" indicator on saved configs (was confusing to see
     an empty input after a successful save).

Schema migrations (additive, idempotent in entrypoint):
  - AIConfigProfile table created
  - UserPreferences.activeAIConfigId
  - AIGeneration.progressText + durationMs
  - ProgramExercise.suggestedWeight + suggestedWeightUnit

Tests: 16 new (systemPromptBase, modelMenu, generationRunner). 177
total pass.
2026-05-11 08:09:01 -05:00

201 lines
7.0 KiB
TypeScript

'use client';
import { usePathname, useRouter } from 'next/navigation';
import {
LayoutDashboard,
Dumbbell,
ListChecks,
Calendar,
Sparkles,
Settings,
LogOut,
} from 'lucide-react';
import { logoutAction } from './actions';
interface NavigationProps {
userName: string;
isAdmin: boolean;
}
interface NavSubItem {
/** Either a route href or a section anchor (#…) on the parent page. */
href: string;
label: string;
/** Admin-only — hidden for non-admin users. */
adminOnly?: boolean;
}
interface NavLink {
href: string;
label: string;
icon: typeof LayoutDashboard;
/** v1.1.0:4 — sub-navigation rendered when the user is on this section.
* Items can either deep-link to a sibling route or scroll to an anchor
* on the parent page. */
subItems?: NavSubItem[];
}
const navLinks: NavLink[] = [
{ href: '/main/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/main/workouts', label: 'Workouts', icon: Dumbbell },
{ href: '/main/programs', label: 'Programs', icon: Calendar },
{
href: '/main/ai',
label: 'AI',
icon: Sparkles,
subItems: [
{ href: '/main/ai/generate', label: 'Generate' },
{ href: '/main/ai/history', label: 'History' },
{ href: '/main/ai/templates', label: 'Templates' },
],
},
{ href: '/main/exercises', label: 'Exercises', icon: ListChecks },
{
href: '/main/settings',
label: 'Settings',
icon: Settings,
subItems: [
{ href: '/main/settings#general', label: 'General' },
{ href: '/main/settings#password', label: 'Password' },
{ href: '/main/settings#sessions', label: 'Sessions' },
{ href: '/main/settings#ai', label: 'AI integration' },
{ href: '/main/settings#data', label: 'Export & import' },
{ href: '/main/settings#instance', label: 'Instance', adminOnly: true },
{ href: '/main/settings#danger', label: 'Danger zone' },
],
},
];
export default function Navigation({ userName, isAdmin }: NavigationProps) {
const pathname = usePathname();
const router = useRouter();
// A top-level item is "active" if the current pathname matches it
// exactly OR is a subpage. We use this to decide whether to expand
// the sub-nav under it.
const isActive = (href: string) =>
pathname === href || pathname.startsWith(href + '/');
// A sub-item's active state depends on what it points to:
// - Route subitem (no #): exact pathname match
// - Anchor subitem (has #): always inactive in nav (anchor change
// doesn't fire pathname). The browser handles the highlight.
const isSubActive = (subHref: string) => {
const [path] = subHref.split('#');
if (subHref.includes('#')) return false;
return pathname === path;
};
const handleLogout = async () => {
await logoutAction();
router.push('/auth/login');
};
return (
<>
{/* Desktop Sidebar */}
<aside className="hidden md:flex fixed left-0 top-0 h-screen w-[var(--sidebar-width)] border-r border-zinc-800 bg-[#0A0A0A] flex-col">
<div className="p-6 border-b border-zinc-800">
<h2 className="text-3xl font-display text-white tracking-wider">Proof of Work</h2>
</div>
<nav className="flex-1 overflow-y-auto p-4 space-y-1">
{navLinks.map((link) => {
const Icon = link.icon;
const active = isActive(link.href);
return (
<div key={link.href}>
<a
href={link.href}
className={`flex items-center gap-3 px-4 py-2.5 rounded transition-all duration-200 ${
active
? 'bg-white text-black font-semibold'
: 'text-zinc-500 hover:text-white hover:bg-zinc-900'
}`}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="text-sm">{link.label}</span>
</a>
{/* Expand sub-nav when this section is active. */}
{active && link.subItems && link.subItems.length > 0 && (
<ul className="ml-4 mt-1 mb-2 border-l border-zinc-800 pl-3 space-y-0.5">
{link.subItems
.filter((s) => !s.adminOnly || isAdmin)
.map((sub) => {
const subActive = isSubActive(sub.href);
return (
<li key={sub.href}>
<a
href={sub.href}
className={`block px-3 py-1.5 rounded text-xs transition-colors ${
subActive
? 'text-white bg-zinc-800'
: 'text-zinc-500 hover:text-white hover:bg-zinc-900'
}`}
>
{sub.label}
</a>
</li>
);
})}
</ul>
)}
</div>
);
})}
</nav>
<div className="border-t border-zinc-800 p-4 space-y-4">
<div className="px-4 py-2">
<p className="text-xs text-zinc-600 uppercase tracking-widest">User</p>
<p className="font-semibold text-white truncate mt-1">{userName}</p>
</div>
<button
onClick={handleLogout}
className="flex items-center gap-3 px-4 py-2.5 rounded transition-all duration-200 w-full justify-start text-red-500 hover:text-red-400 hover:bg-red-950/30"
>
<LogOut className="w-5 h-5 flex-shrink-0" />
<span className="text-sm">Logout</span>
</button>
</div>
</aside>
{/* Mobile Bottom Nav (no sub-nav — limited screen real estate) */}
<header className="flex md:hidden fixed bottom-0 left-0 right-0 border-t border-zinc-800 bg-[#0A0A0A]">
<nav className="flex items-center justify-around h-[var(--bottom-nav-height)] w-full">
{navLinks.map((link) => {
const Icon = link.icon;
const active = isActive(link.href);
return (
<a
key={link.href}
href={link.href}
className={`flex flex-col items-center justify-center flex-1 h-full gap-1 transition-colors duration-200 ${
active
? 'text-white bg-zinc-900'
: 'text-zinc-500 hover:text-white'
}`}
>
<Icon className="w-6 h-6" />
<span className="text-xs">{link.label}</span>
</a>
);
})}
<button
onClick={handleLogout}
className="flex flex-col items-center justify-center flex-1 h-full gap-1 text-red-500 hover:text-red-400 transition-colors duration-200"
>
<LogOut className="w-6 h-6" />
<span className="text-xs">Logout</span>
</button>
</nav>
</header>
</>
);
}