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/app/api-keys/page.tsx b/apps/web/src/app/app/api-keys/page.tsx
index 08739790..62118d9e 100644
--- a/apps/web/src/app/app/api-keys/page.tsx
+++ b/apps/web/src/app/app/api-keys/page.tsx
@@ -4,7 +4,7 @@ import { useEffect, useRef } from "react"
import { AddApiKeyDialog } from "@/components/api-key-vault/add-api-key-dialog"
import { ApiKeyList } from "@/components/api-key-vault/api-key-list"
import { useApiKeyVaultStore, type ApiKeyEntry, type ApiKeyEnv } from "@/store/api-key-vault-store"
-import { ShieldCheck } from "lucide-react"
+import { ToolHeader } from "@/components/tools/tool-header"
import { useVaultGuard } from "@/hooks/use-vault-guard"
import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder"
import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton"
@@ -44,7 +44,7 @@ export default function ApiKeyVaultPage() {
const { user, loading } = useAuth(true)
const encryptionKey = useCipherKey()
const { isUnlocked, isRestoring } = useVaultGuard()
- const { entries, setEntries, setLoading, clearEntries } = useApiKeyVaultStore()
+ const { setEntries, setLoading, clearEntries } = useApiKeyVaultStore()
const isMobile = useIsMobile()
const loadedRef = useRef(false)
@@ -130,39 +130,19 @@ export default function ApiKeyVaultPage() {
: "h-full flex flex-col container mx-auto px-4 md:px-6 lg:px-8 min-h-0"
}
>
+
+
{!isMobile && (
-
-
-
-
API Keys
- {entries.length > 0 && (
-
- {entries.length}
-
- )}
-
-
-
- AES-256-GCM encrypted on your device — server never sees plaintext.
-
-
+
)}
- {isMobile && (
-
-
-
API Keys
- {entries.length > 0 && (
-
- {entries.length} stored
-
- )}
-
-
- )}
-
diff --git a/apps/web/src/app/app/environment-manager/page.tsx b/apps/web/src/app/app/environment-manager/page.tsx
index 6e2e115a..f30ce106 100644
--- a/apps/web/src/app/app/environment-manager/page.tsx
+++ b/apps/web/src/app/app/environment-manager/page.tsx
@@ -3,6 +3,7 @@
import { useEffect, useRef } from "react"
import { AddEnvironmentSetDialog } from "@/components/environment-manager/add-environment-set-dialog"
import { EnvironmentSetList } from "@/components/environment-manager/environment-set-list"
+import { ToolHeader } from "@/components/tools/tool-header"
import { useEnvironmentManagerStore, type EnvSetEntry } from "@/store/environment-manager-store"
import { useVaultGuard } from "@/hooks/use-vault-guard"
import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder"
@@ -118,9 +119,11 @@ export default function EnvironmentManagerPage() {
}
>
{!isMobile && (
-
-
{t("title")}
-
+
)}
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 (
+
+
+
+
+
+
+
+ Your name
+
+ 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 && (
{activeOrg.name}
diff --git a/apps/web/src/components/sidebar/app-sidebar.tsx b/apps/web/src/components/sidebar/app-sidebar.tsx
index 87f75274..89e16d3c 100644
--- a/apps/web/src/components/sidebar/app-sidebar.tsx
+++ b/apps/web/src/components/sidebar/app-sidebar.tsx
@@ -15,7 +15,6 @@ import {
import { NavGroup } from './nav-group'
import { NavUser } from './nav-user'
import { FeedbackDialog } from '@/components/feedback-dialog'
-import { OrgSwitcherDropdown } from '@/components/org-switcher-dropdown'
import { Logo } from '../logo'
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'
import { LayoutDashboard } from 'lucide-react'
@@ -144,9 +143,6 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
Developer's Toolkit
-
-
-
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/components/tools/tool-header.tsx b/apps/web/src/components/tools/tool-header.tsx
index d4c8a589..2cd25321 100644
--- a/apps/web/src/components/tools/tool-header.tsx
+++ b/apps/web/src/components/tools/tool-header.tsx
@@ -3,6 +3,7 @@
import { Heart } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
+import { getRouteConfig } from '@/lib/route-config';
import { usePinnedToolsStore, usePinnedToolsForActiveWorkspace } from '@/store/pinned-tools-store';
import { useWorkspaceStore } from '@/store/workspace-store';
import { normalizePinnedToolPath } from '@/lib/pinned-tools-path';
@@ -74,19 +75,31 @@ export function ToolHeader({ title, description, toolId, className }: ToolHeader
);
}
+ // Compact single-row header — the tool's identity already lives in the tab
+ // and sidebar, so the in-page header stays out of the content's way.
+ const Icon = getRouteConfig(normalizePinnedToolPath(toolId))?.icon;
+
return (
-
-
-
- {title?.trim() ? (
-
- {title}
-
- ) : null}
- {description ?
{description} : null}
-
+
+ {Icon ? (
+
+
+
+ ) : null}
+
+ {title?.trim() ? (
+ {title}
+ ) : null}
+ {description ? (
+ {description}
+ ) : null}
-
+
);
}
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()
+ })
+}