diff --git a/apps/api-runner/node_modules/.bin/tsc b/apps/api-runner/node_modules/.bin/tsc index 83c6daaa..44a8dcb8 100755 --- a/apps/api-runner/node_modules/.bin/tsc +++ b/apps/api-runner/node_modules/.bin/tsc @@ -2,16 +2,21 @@ basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") case `uname` in - *CYGWIN*) basedir=`cygpath -w "$basedir"`;; + *CYGWIN*|*MINGW*|*MSYS*) + if command -v cygpath > /dev/null 2>&1; then + basedir=`cygpath -w "$basedir"` + fi + ;; esac if [ -z "$NODE_PATH" ]; then - export NODE_PATH="/Users/max/Works/Personal/mydevtools.tech/.claude/worktrees/formatters-dashboard-alignment/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/max/Works/Personal/mydevtools.tech/.claude/worktrees/formatters-dashboard-alignment/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/max/Works/Personal/mydevtools.tech/.claude/worktrees/formatters-dashboard-alignment/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/max/Works/Personal/mydevtools.tech/.claude/worktrees/formatters-dashboard-alignment/node_modules/.pnpm/node_modules" + export NODE_PATH="/Users/anuphilipp/Documents/GitHub/mydevtools/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/anuphilipp/Documents/GitHub/mydevtools/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/anuphilipp/Documents/GitHub/mydevtools/node_modules/.pnpm/node_modules" else - export NODE_PATH="/Users/max/Works/Personal/mydevtools.tech/.claude/worktrees/formatters-dashboard-alignment/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/max/Works/Personal/mydevtools.tech/.claude/worktrees/formatters-dashboard-alignment/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/max/Works/Personal/mydevtools.tech/.claude/worktrees/formatters-dashboard-alignment/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/max/Works/Personal/mydevtools.tech/.claude/worktrees/formatters-dashboard-alignment/node_modules/.pnpm/node_modules:$NODE_PATH" + export NODE_PATH="/Users/anuphilipp/Documents/GitHub/mydevtools/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/anuphilipp/Documents/GitHub/mydevtools/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/anuphilipp/Documents/GitHub/mydevtools/node_modules/.pnpm/node_modules:$NODE_PATH" fi if [ -x "$basedir/node" ]; then exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@" else exec node "$basedir/../typescript/bin/tsc" "$@" fi +# cmd-shim-target=/Users/anuphilipp/Documents/GitHub/mydevtools/apps/api-runner/node_modules/typescript/bin/tsc diff --git a/apps/api-runner/node_modules/.bin/tsserver b/apps/api-runner/node_modules/.bin/tsserver index 07973412..0acb3ed7 100755 --- a/apps/api-runner/node_modules/.bin/tsserver +++ b/apps/api-runner/node_modules/.bin/tsserver @@ -2,16 +2,21 @@ basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") case `uname` in - *CYGWIN*) basedir=`cygpath -w "$basedir"`;; + *CYGWIN*|*MINGW*|*MSYS*) + if command -v cygpath > /dev/null 2>&1; then + basedir=`cygpath -w "$basedir"` + fi + ;; esac if [ -z "$NODE_PATH" ]; then - export NODE_PATH="/Users/max/Works/Personal/mydevtools.tech/.claude/worktrees/formatters-dashboard-alignment/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/max/Works/Personal/mydevtools.tech/.claude/worktrees/formatters-dashboard-alignment/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/max/Works/Personal/mydevtools.tech/.claude/worktrees/formatters-dashboard-alignment/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/max/Works/Personal/mydevtools.tech/.claude/worktrees/formatters-dashboard-alignment/node_modules/.pnpm/node_modules" + export NODE_PATH="/Users/anuphilipp/Documents/GitHub/mydevtools/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/anuphilipp/Documents/GitHub/mydevtools/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/anuphilipp/Documents/GitHub/mydevtools/node_modules/.pnpm/node_modules" else - export NODE_PATH="/Users/max/Works/Personal/mydevtools.tech/.claude/worktrees/formatters-dashboard-alignment/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/max/Works/Personal/mydevtools.tech/.claude/worktrees/formatters-dashboard-alignment/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/max/Works/Personal/mydevtools.tech/.claude/worktrees/formatters-dashboard-alignment/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/max/Works/Personal/mydevtools.tech/.claude/worktrees/formatters-dashboard-alignment/node_modules/.pnpm/node_modules:$NODE_PATH" + export NODE_PATH="/Users/anuphilipp/Documents/GitHub/mydevtools/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/anuphilipp/Documents/GitHub/mydevtools/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/anuphilipp/Documents/GitHub/mydevtools/node_modules/.pnpm/node_modules:$NODE_PATH" fi if [ -x "$basedir/node" ]; then exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@" else exec node "$basedir/../typescript/bin/tsserver" "$@" fi +# cmd-shim-target=/Users/anuphilipp/Documents/GitHub/mydevtools/apps/api-runner/node_modules/typescript/bin/tsserver diff --git a/apps/backend/app/api/routes/auth/api.py b/apps/backend/app/api/routes/auth/api.py index ac058f5c..11d2576a 100644 --- a/apps/backend/app/api/routes/auth/api.py +++ b/apps/backend/app/api/routes/auth/api.py @@ -257,6 +257,9 @@ async def update_profile( if payload.personal_info is not None: updates["personal_info"] = payload.personal_info.model_dump() + if payload.persona is not None: + updates["persona"] = payload.persona + if updates: await update_user_profile(current_user.uid, updates) diff --git a/apps/backend/app/api/routes/auth/schema.py b/apps/backend/app/api/routes/auth/schema.py index c7450d59..e6a89181 100644 --- a/apps/backend/app/api/routes/auth/schema.py +++ b/apps/backend/app/api/routes/auth/schema.py @@ -108,6 +108,8 @@ class UserProfileResponse(BaseModel): certifications: list[Certification] = Field(default_factory=list) portfolio_settings: PortfolioSettings | None = None personal_info: PersonalInfo | None = None + # Role picked during onboarding (frontend/backend/fullstack/devops/qa/designer/exploring) + persona: str | None = None onboarding_completed: bool = False # Workspace migration fields (T25) workspace_setup_at: int | None = None @@ -128,6 +130,7 @@ class UpdateProfileRequest(BaseModel): certifications: list[Certification] | None = None portfolio_settings: PortfolioSettings | None = None personal_info: PersonalInfo | None = None + persona: str | None = None class OkResponse(BaseModel): diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index 3c40b718..56ec2053 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -273,7 +273,7 @@ const DashboardPage: React.FC = () => { mobileOnly /> -
+
{/* ── Desktop Hero (inside padded container for alignment) ── */} diff --git a/apps/web/src/components/global-command-palette.tsx b/apps/web/src/components/global-command-palette.tsx index 78b3378e..584982af 100644 --- a/apps/web/src/components/global-command-palette.tsx +++ b/apps/web/src/components/global-command-palette.tsx @@ -154,7 +154,13 @@ export function GlobalCommandPalette() { ) const commandSurfaceClass = - '[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5' + '[&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:pt-2.5 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:text-[10px] [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-[0.14em] [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-14 [&_[cmdk-input]]:text-base' + + // Shared row treatment — gradient selection sweep + icon chip (Raycast-style). + const itemClass = + 'group mx-1 flex items-center gap-3 rounded-xl px-2 py-2 transition-colors aria-selected:bg-gradient-to-r aria-selected:from-primary/[0.14] aria-selected:via-primary/[0.06] aria-selected:to-transparent aria-selected:ring-1 aria-selected:ring-inset aria-selected:ring-primary/20' + const chipClass = + 'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted/60 text-muted-foreground ring-1 ring-inset ring-border/50 transition-colors group-aria-selected:bg-gradient-to-br group-aria-selected:from-primary/20 group-aria-selected:to-violet-500/10 group-aria-selected:text-primary group-aria-selected:ring-primary/20' return ( @@ -162,10 +168,17 @@ export function GlobalCommandPalette() { showCloseButton={false} className={cn( 'overflow-hidden p-0 gap-0 max-w-2xl', - 'max-h-[min(85vh,640px)] flex flex-col' + 'max-h-[min(85vh,640px)] flex flex-col', + 'rounded-2xl border-border/60 bg-popover/90 backdrop-blur-2xl', + 'shadow-2xl shadow-primary/10 ring-1 ring-inset ring-white/[0.04]' )} > Search tools and pages + {/* Gradient hairline — the palette's signature accent */} +
No results found. @@ -191,15 +204,17 @@ export function GlobalCommandPalette() { key={`recent-${entry.url}`} value={`recent ${entry.searchValue} ${entry.url}`.toLowerCase()} onSelect={() => run(entry)} - className="mx-1 flex items-start gap-3 rounded-lg py-2.5 transition-colors aria-selected:bg-gradient-to-r aria-selected:from-primary/[0.12] aria-selected:to-primary/[0.04] aria-selected:ring-1 aria-selected:ring-inset aria-selected:ring-primary/15" + className={itemClass} > - + + + {entry.title} {entry.description ? ( - + {entry.description} ) : null} @@ -221,24 +236,23 @@ export function GlobalCommandPalette() { key={`pinned-${entry.url}`} value={`pinned ${entry.searchValue} ${entry.url}`.toLowerCase()} onSelect={() => run(entry)} - className="mx-1 flex items-start gap-3 rounded-lg py-2.5 transition-colors aria-selected:bg-gradient-to-r aria-selected:from-primary/[0.12] aria-selected:to-primary/[0.04] aria-selected:ring-1 aria-selected:ring-inset aria-selected:ring-primary/15" + className={itemClass} > - + + + {entry.title} {entry.description ? ( - + {entry.description} ) : null} - @@ -259,18 +273,17 @@ export function GlobalCommandPalette() { key={entry.url} value={`${entry.searchValue} ${entry.url}`.toLowerCase()} onSelect={() => run(entry)} - className="flex items-start gap-3 py-2.5 aria-selected:bg-accent" + className={itemClass} > - + + + {entry.title} {entry.description ? ( - + {entry.description} ) : null} @@ -282,29 +295,29 @@ export function GlobalCommandPalette() { ))} -
- - +
+ + ↑↓ - {' '} + navigate - - + + ↵ - {' '} + open - - + + esc - {' '} + close - - + + {modLabel}K - {' '} + toggle
diff --git a/apps/web/src/components/nav-bar.tsx b/apps/web/src/components/nav-bar.tsx index faa0078c..2997a7c2 100644 --- a/apps/web/src/components/nav-bar.tsx +++ b/apps/web/src/components/nav-bar.tsx @@ -7,8 +7,8 @@ import { ModeToggle } from "@/components/modeToggle"; import { getToolMessageKey } from "@/lib/tool-i18n"; import { routeConfig } from "@/lib/route-config"; import { WorkspaceSwitcherDropdown } from "@/components/workspace-switcher-dropdown"; +import { OrgSwitcherDropdown } from "@/components/org-switcher-dropdown"; import { WorkspaceQuickActions } from "@/components/workspace-quick-actions"; -import { WorkspaceBreadcrumb } from "@/components/workspace-breadcrumb"; import { NotificationsBell } from "@/components/notifications-bell"; function openCommandPalette() { @@ -27,7 +27,7 @@ function CommandTrigger() { > Search or jump to… - + ⌘K @@ -63,19 +63,23 @@ export function NavBar() { const config = match?.[1]; const matchedRoute = match?.[0]; - if (!config) { + // The dashboard needs no page title — the sidebar highlights it and the page + // greets the user. A "Dashboard" label beside the org switcher is noise. + if (!config || matchedRoute === '/dashboard') { // Minimal bar everywhere with a sidebar so chrome (workspace, bell, theme) // is reachable on /dashboard, /settings, etc. — not just /app routes. return (
-
+
+
+ +
-
); } @@ -94,6 +98,8 @@ export function NavBar() {
+ +
@@ -116,7 +122,6 @@ export function NavBar() {
-
); } diff --git a/apps/web/src/components/onboarding-modal.tsx b/apps/web/src/components/onboarding-modal.tsx index 284f24eb..9b74aacc 100644 --- a/apps/web/src/components/onboarding-modal.tsx +++ b/apps/web/src/components/onboarding-modal.tsx @@ -5,9 +5,16 @@ import { motion, AnimatePresence } from "framer-motion" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { Progress } from "@/components/ui/progress" -import { completeOnboarding } from "@/lib/onboarding-api" +import { completeOnboarding, savePersona } from "@/lib/onboarding-api" import { useToolVisibilityStore } from "@/store/tool-visibility-store" +import { usePinnedToolsStore } from "@/store/pinned-tools-store" +import { useWorkspaceStore } from "@/store/workspace-store" import { patchUserPreferences } from "@/lib/user-preferences-api" +import { ONBOARDING_ROLES, type OnboardingRole } from "@/lib/onboarding-roles" +import { Input } from "@/components/ui/input" +import useAuth from "@/utils/useAuth" +import { updateProfile } from "firebase/auth" +import { auth } from "@/database/firebase" import { IconRocket, IconShield, @@ -172,7 +179,105 @@ function WelcomeStep() { ) } -// ── Step 1: Tool selection ──────────────────────────────────────────────────── +// ── Step 1: About you (name + role) ─────────────────────────────────────────── + +function AboutYouStep({ + name, + onNameChange, + roleId, + onRoleChange, +}: { + name: string + onNameChange: (v: string) => void + roleId: string | null + onRoleChange: (role: OnboardingRole) => void +}) { + return ( +
+
+

Step 1 · About you

+

Make it yours

+

+ We'll curate your toolkit around what you do. +

+
+ + +
+
+ + onNameChange(e.target.value)} + placeholder="Ada Lovelace" + maxLength={80} + className="h-11 max-w-sm bg-white/5" + /> +
+ +
+

+ What describes you best? +

+
+ {ONBOARDING_ROLES.map((role) => { + const selected = roleId === role.id + const RoleIcon = role.Icon + return ( + onRoleChange(role)} + whileTap={{ scale: 0.98 }} + className={cn( + "group flex items-center gap-3 rounded-xl border p-3.5 text-left transition-all duration-150", + selected + ? "border-primary/50 bg-gradient-to-r from-primary/[0.14] to-violet-500/[0.06] ring-1 ring-inset ring-primary/25" + : "border-white/10 bg-white/[0.03] hover:border-primary/30 hover:bg-white/[0.06]" + )} + > + + + + + + {role.label} + + {role.description} + + + {selected && } + + + ) + })} +
+

+ This pre-selects a toolkit for you — you can fine-tune it on the next step. +

+
+
+
+
+ ) +} + +// ── Step 2: Tool selection ──────────────────────────────────────────────────── /** Flip this to switch between selection UIs without touching anything else. */ const TOOL_SELECTION_MODE: "tags" | "checkboxes" = "tags" @@ -544,7 +649,9 @@ function TourStep({ slideIndex, onSlide }: { slideIndex: number; onSlide: (i: nu // ── Root modal ──────────────────────────────────────────────────────────────── -const STEPS = ["Welcome", "Choose tools", "Tour"] +const STEPS = ["Welcome", "About you", "Choose tools", "Tour"] +const TOOLS_STEP = 2 +const TOUR_STEP = 3 interface OnboardingModalProps { onComplete: () => void @@ -557,12 +664,29 @@ export function OnboardingModal({ onComplete }: OnboardingModalProps) { const [celebrating, setCelebrating] = useState(false) const setEnabledTools = useToolVisibilityStore((s) => s.setEnabledTools) + const setPinnedTools = usePinnedToolsStore((s) => s.setPinnedTools) + const activeWorkspaceId = useWorkspaceStore((s) => s.activeWorkspaceId) + + const { user } = useAuth(false) + const [name, setName] = useState("") + const [role, setRole] = useState(null) + + // Pre-fill the name from the auth profile (don't ask what we already know). + React.useEffect(() => { + if (user?.displayName) setName((prev) => prev || user.displayName!) + }, [user?.displayName]) const [selectedUrls, setSelectedUrls] = useState>(() => { const productivity = buildCategories().find((c) => c.title === "Productivity") return new Set(productivity?.tools.map((t) => t.url) ?? []) }) + const handleRoleChange = useCallback((next: OnboardingRole) => { + setRole(next) + // Role picks a curated starting toolkit; the next step lets them fine-tune. + setSelectedUrls(new Set(next.tools)) + }, []) + const handleToggleTool = useCallback((url: string) => { setSelectedUrls((prev) => { const next = new Set(prev) @@ -590,7 +714,7 @@ export function OnboardingModal({ onComplete }: OnboardingModalProps) { const handleNext = async () => { // Advance tour slides before completing - if (step === 2 && tourSlide < TOUR_SLIDES.length - 1) { + if (step === TOUR_STEP && tourSlide < TOUR_SLIDES.length - 1) { setTourSlide((s) => s + 1) return } @@ -606,9 +730,22 @@ export function OnboardingModal({ onComplete }: OnboardingModalProps) { try { const tools = Array.from(selectedUrls) setEnabledTools(tools) + // Role's core tools become the pinned set for the active workspace — + // this is what makes the first dashboard feel personally curated. + const pins = (role?.pins ?? []).filter((p) => selectedUrls.has(p)) + const prefsPatch: Parameters[0] = { enabledTools: tools } + if (activeWorkspaceId && pins.length) { + setPinnedTools(activeWorkspaceId, pins) + prefsPatch.pinnedToolsByWorkspace = { [activeWorkspaceId]: pins } + } + const trimmedName = name.trim() await Promise.all([ completeOnboarding(), - patchUserPreferences({ enabledTools: tools }), + patchUserPreferences(prefsPatch), + role ? savePersona(role.id).catch(() => {}) : Promise.resolve(), + trimmedName && auth.currentUser && trimmedName !== auth.currentUser.displayName + ? updateProfile(auth.currentUser, { displayName: trimmedName }).catch(() => {}) + : Promise.resolve(), ]) } catch { // non-blocking — user can still proceed @@ -621,23 +758,26 @@ export function OnboardingModal({ onComplete }: OnboardingModalProps) { } const handleBack = () => { - if (step === 2 && tourSlide > 0) { + if (step === TOUR_STEP && tourSlide > 0) { setTourSlide((s) => s - 1) return } if (step > 0) setStep((s) => s - 1) } - const progress = ((step + (step === 2 ? tourSlide / TOUR_SLIDES.length : 0)) / (STEPS.length - 1)) * 100 + const progress = ((step + (step === TOUR_STEP ? tourSlide / TOUR_SLIDES.length : 0)) / (STEPS.length - 1)) * 100 const isLastAction = step === STEPS.length - 1 && tourSlide === TOUR_SLIDES.length - 1 + // Role selection is the one thing we need before curating (step 1). + const nextDisabled = completing || (step === 1 && !role) + const nextLabel = isLastAction ? completing ? "Saving…" : "Get started" - : step === 2 + : step === TOUR_STEP ? "Next tip" : "Continue" @@ -711,6 +851,23 @@ export function OnboardingModal({ onComplete }: OnboardingModalProps) { )} {step === 1 && ( + + + + )} + {step === TOOLS_STEP && ( )} - {step === 2 && ( + {step === TOUR_STEP && (
-
- -
diff --git a/apps/web/src/components/sidebar/client-layout.tsx b/apps/web/src/components/sidebar/client-layout.tsx index 1419ca7b..d836f5d8 100644 --- a/apps/web/src/components/sidebar/client-layout.tsx +++ b/apps/web/src/components/sidebar/client-layout.tsx @@ -5,7 +5,6 @@ import { SidebarProvider, useSidebar } from "@/components/ui/sidebar"; import { AppSidebar } from "./app-sidebar"; import { NavBar } from '@/components/nav-bar'; import { MobileNav } from '@/components/mobile-nav'; -import { GlobalCommandPalette } from '@/components/global-command-palette'; import { TabBar } from '@/components/tab-bar/tab-bar'; import { useTabStore } from '@/store/tab-store'; import { isTabRoute } from '@/lib/route-config'; @@ -14,6 +13,12 @@ import { MigrationBanner } from '@/components/migration-banner'; import { MobileDesktopHint } from '@/components/mobile-desktop-hint'; import { WorkspaceSwitcherDropdown } from '@/components/workspace-switcher-dropdown'; import { NotificationsBell } from '@/components/notifications-bell'; +import { useWorkspaceStore } from '@/store/workspace-store'; +import { initWorkspaceScopeReset } from '@/lib/workspace-scope-reset'; + +// Reset workspace-scoped stores whenever the active workspace changes +// (module-level, mirrors workspace-store's own subscribeOnce pattern). +initWorkspaceScopeReset(); // Renders all open tool tabs simultaneously. The active tab is visible; // inactive tabs use display:none to stay mounted (preserving their state). @@ -59,6 +64,10 @@ function Layout({ children }: { children: React.ReactNode }) { const { state } = useSidebar(); const pathname = usePathname(); const { tabs } = useTabStore(); + // Keying tool content by workspace remounts every mounted tab (and page) on + // switch, so each tool refetches under the new scope instead of showing the + // previous workspace's data. + const activeWorkspaceId = useWorkspaceStore((s) => s.activeWorkspaceId); // Open command palette for new tab via "+" button const openCommandPalette = useCallback(() => { @@ -104,9 +113,12 @@ function Layout({ children }: { children: React.ReactNode }) {
{inTabMode ? ( - + ) : ( -
+
{children}
@@ -116,7 +128,8 @@ function Layout({ children }: { children: React.ReactNode }) {
- + {/* Command palette is mounted once globally in ClientShell (root layout) — + a second mount here caused stacked overlays needing two clicks to dismiss. */}
); } diff --git a/apps/web/src/components/tab-bar/tab-bar.tsx b/apps/web/src/components/tab-bar/tab-bar.tsx index 20e962dd..1a451c7d 100644 --- a/apps/web/src/components/tab-bar/tab-bar.tsx +++ b/apps/web/src/components/tab-bar/tab-bar.tsx @@ -6,6 +6,11 @@ import { X, Plus, ChevronLeft, ChevronRight } from 'lucide-react' import { useTabStore } from '@/store/tab-store' import { getRouteConfig } from '@/lib/route-config' import { cn } from '@/lib/utils' +import { OrgSwitcherDropdown } from '@/components/org-switcher-dropdown' +import { WorkspaceSwitcherDropdown } from '@/components/workspace-switcher-dropdown' +import { WorkspaceQuickActions } from '@/components/workspace-quick-actions' +import { NotificationsBell } from '@/components/notifications-bell' +import { ModeToggle } from '@/components/modeToggle' import { Tooltip, TooltipContent, @@ -90,8 +95,7 @@ export function TabBar({ onNewTab }: TabBarProps) { if (path !== pathname) router.push(path) } - function handleClose(e: React.MouseEvent, path: string) { - e.stopPropagation() + const closeTabAndNavigate = useCallback((path: string) => { const { tabs, activeTabPath } = useTabStore.getState() const idx = tabs.findIndex(t => t.path === path) const newTabs = tabs.filter(t => t.path !== path) @@ -100,11 +104,44 @@ export function TabBar({ onNewTab }: TabBarProps) { router.push(next ?? '/dashboard') } closeTab(path) + }, [router, closeTab]) + + function handleClose(e: React.MouseEvent, path: string) { + e.stopPropagation() + closeTabAndNavigate(path) } + // Keyboard shortcuts: ⌥1–9 jump to tab, ⌥W close active tab. + // (⌘W / ⌘1–9 are browser-reserved and cannot be intercepted.) + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (!e.altKey || e.metaKey || e.ctrlKey || e.shiftKey) return + const t = e.target as HTMLElement | null + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return + // e.code is layout-independent — on macOS ⌥ changes e.key ('w' → '∑'). + if (e.code === 'KeyW') { + const { activeTabPath } = useTabStore.getState() + if (!activeTabPath) return + e.preventDefault() + closeTabAndNavigate(activeTabPath) + return + } + const digit = /^Digit([1-9])$/.exec(e.code) + if (digit) { + const { tabs, activeTabPath } = useTabStore.getState() + const tab = tabs[Number(digit[1]) - 1] + if (!tab) return + e.preventDefault() + if (tab.path !== activeTabPath) router.push(tab.path) + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [router, closeTabAndNavigate]) + return ( -
+
{/* Left scroll arrow */} - + {title} + {i < 9 && ( + + ⌥{i + 1} + + )} ) @@ -211,6 +253,17 @@ export function TabBar({ onNewTab }: TabBarProps) { Open tool (⌘K) + + {/* Global context chrome — same set & order as the NavBar so tab mode + never hides which org/workspace the open tools are scoped to. */} +
+
+ + + + + +
) diff --git a/apps/web/src/lib/onboarding-api.ts b/apps/web/src/lib/onboarding-api.ts index d90ee5b6..19faa8b0 100644 --- a/apps/web/src/lib/onboarding-api.ts +++ b/apps/web/src/lib/onboarding-api.ts @@ -17,3 +17,13 @@ export async function completeOnboarding(): Promise { }) if (!res.ok) throw new Error("Failed to complete onboarding") } + +/** Persist the role picked during onboarding on the user profile. */ +export async function savePersona(persona: string): Promise { + const res = await backendFetch("/api/backend/auth/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ persona }), + }) + if (!res.ok) throw new Error("Failed to save persona") +} diff --git a/apps/web/src/lib/onboarding-roles.ts b/apps/web/src/lib/onboarding-roles.ts new file mode 100644 index 00000000..a6a9baa0 --- /dev/null +++ b/apps/web/src/lib/onboarding-roles.ts @@ -0,0 +1,163 @@ +import type { ElementType } from "react" +import { + IconBrowser, + IconServer2, + IconStack2, + IconCloudCog, + IconBug, + IconBrush, + IconCompass, +} from "@tabler/icons-react" + +/** + * Onboarding role presets. Each role curates: + * - `pins`: ~8 core tools pinned to the dashboard/sidebar (the daily drivers) + * - `tools`: the broader enabled set shown in the sidebar (pins + likely-useful) + * Users can adjust the selection on the next step; this is a starting point, + * not a restriction. + */ + +// Everyone gets the productivity basics — they're the platform's sticky core. +const BASICS = ["/app/bookmarks", "/app/notes", "/app/to-do", "/app/snippet-manager"] + +export interface OnboardingRole { + id: string + label: string + description: string + Icon: ElementType + pins: string[] + tools: string[] +} + +function role( + id: string, + label: string, + description: string, + Icon: ElementType, + pins: string[], + extras: string[] = [], +): OnboardingRole { + return { id, label, description, Icon, pins, tools: [...new Set([...BASICS, ...pins, ...extras])] } +} + +export const ONBOARDING_ROLES: OnboardingRole[] = [ + role( + "frontend", + "Frontend developer", + "UI, styling, and browser-side work", + IconBrowser, + [ + "/app/json-formatter", + "/app/api-client", + "/app/color-picker", + "/app/contrast-checker", + "/app/css-gradient-builder", + "/app/svg-optimizer", + "/app/regex-tester", + "/app/image-compressor", + ], + ["/app/lorem-ipsum", "/app/markdown-preview-html", "/app/base64", "/app/diff-checker", "/app/image-to-base64"], + ), + role( + "backend", + "Backend developer", + "APIs, databases, and server-side logic", + IconServer2, + [ + "/app/api-client", + "/app/sql-client", + "/app/database-explorer", + "/app/redis-commander", + "/app/jwt-decoder", + "/app/cron-builder", + "/app/environment-manager", + "/app/uuid-generator", + ], + ["/app/sql-formatter", "/app/mock-data-generator", "/app/hash-generator", "/app/http-status-codes", "/app/api-keys"], + ), + role( + "fullstack", + "Full-stack developer", + "A bit of everything, end to end", + IconStack2, + [ + "/app/api-client", + "/app/json-formatter", + "/app/sql-client", + "/app/jwt-decoder", + "/app/environment-manager", + "/app/regex-tester", + "/app/docker-compose-generator", + "/app/uuid-generator", + ], + ["/app/database-explorer", "/app/diff-checker", "/app/http-status-codes", "/app/color-picker"], + ), + role( + "devops", + "DevOps / SRE", + "Infra, networking, and deployments", + IconCloudCog, + [ + "/app/docker-compose-generator", + "/app/cron-builder", + "/app/dns-lookup", + "/app/ip-subnet-calculator", + "/app/ssh-key-generator", + "/app/gitignore-generator", + "/app/environment-manager", + "/app/certificate-pem-decoder", + ], + ["/app/hash-generator", "/app/yaml-formatter", "/app/api-client", "/app/timestamp-converter"], + ), + role( + "qa", + "QA / Tester", + "Testing, validation, and data checks", + IconBug, + [ + "/app/api-client", + "/app/mock-data-generator", + "/app/regex-tester", + "/app/diff-checker", + "/app/email-validator", + "/app/json-formatter", + "/app/csv-excel-json", + "/app/http-status-codes", + ], + ["/app/uuid-generator", "/app/user-agent-parser", "/app/url-parser", "/app/timestamp-converter"], + ), + role( + "designer", + "Designer", + "Color, imagery, and visual assets", + IconBrush, + [ + "/app/color-picker", + "/app/contrast-checker", + "/app/css-gradient-builder", + "/app/image-compressor", + "/app/svg-optimizer", + "/app/lorem-ipsum", + "/app/qr-code-generator", + "/app/image-to-base64", + ], + ["/app/markdown-preview-html", "/app/unit-converter"], + ), + role( + "exploring", + "Just exploring", + "Show me a bit of everything", + IconCompass, + [], + [ + "/app/json-formatter", + "/app/api-client", + "/app/regex-tester", + "/app/uuid-generator", + "/app/color-picker", + "/app/base64", + "/app/diff-checker", + "/app/qr-code-generator", + ], + ), +] diff --git a/apps/web/src/lib/workspace-scope-reset.ts b/apps/web/src/lib/workspace-scope-reset.ts new file mode 100644 index 00000000..b3bdad60 --- /dev/null +++ b/apps/web/src/lib/workspace-scope-reset.ts @@ -0,0 +1,42 @@ +// Clears in-memory copies of workspace-scoped data whenever the active +// workspace changes, so no tool ever shows the previous workspace's data. +// IN-MEMORY ONLY — must never call actions that mutate backend data. +// +// Works together with the `key={activeWorkspaceId}` remount in client-layout: +// the remount re-runs every tool's load effect; these resets make sure +// load-once guards (e.g. bookmarks' `hasSynced`) actually refetch. +import { useWorkspaceStore } from "@/store/workspace-store" +import { useBookmarkStore } from "@/store/bookmark-store" +import { usePasswordStore } from "@/store/password-store" +import { useEnvironmentManagerStore } from "@/store/environment-manager-store" +import { useApiKeyVaultStore } from "@/store/api-key-vault-store" + +let initialized = false +let prevWorkspaceId: string | null = null + +export function initWorkspaceScopeReset() { + if (initialized) return + initialized = true + + prevWorkspaceId = useWorkspaceStore.getState().activeWorkspaceId + + useWorkspaceStore.subscribe((state) => { + const next = state.activeWorkspaceId + if (next === prevWorkspaceId) return + const isRealSwitch = prevWorkspaceId !== null && next !== null + prevWorkspaceId = next + if (!isRealSwitch) return // initial hydration, not a switch + + // Bookmarks: persisted + load-once guard — reset both so it refetches. + useBookmarkStore.setState({ + bookmarks: [], + folders: [], + selectedFolderId: null, + hasSynced: false, + }) + // Secret tools: in-memory decrypted data — drop it at the scope boundary. + usePasswordStore.getState().clearPasswords() + useEnvironmentManagerStore.getState().clearSets() + useApiKeyVaultStore.getState().clearEntries() + }) +}