diff --git a/src/components/app/CommandPalette.svelte b/src/components/app/CommandPalette.svelte
new file mode 100644
index 0000000000..cab20854fb
--- /dev/null
+++ b/src/components/app/CommandPalette.svelte
@@ -0,0 +1,166 @@
+
+
+
+
+
+
+
+
+
+ No commands found.
+ {#each [...groupedCommands] as [category, commands]}
+
+ {#each commands as command}
+ handleSelect(command)} disabled={!command.enabled}>
+ {command.label}
+ {#if command.disabledReason}
+ {command.disabledReason}
+ {:else if command.shortcut}
+
+ {command.shortcut()}
+
+ {/if}
+
+ {/each}
+
+ {/each}
+
+
+
+
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index bbc137ee56..3b85bd4645 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -7,11 +7,16 @@
import WarningIcon from '@nasa-jpl/stellar/icons/warning.svg?component';
import { mergeWith } from 'lodash-es';
import { onMount } from 'svelte';
+ import CommandPalette from '../components/app/CommandPalette.svelte';
import Nav from '../components/app/Nav.svelte';
import Loading from '../components/Loading.svelte';
+ import { closeCommandPalette } from '../stores/commandPalette';
import { clearLogs } from '../stores/errors';
import { plugins, pluginsError, pluginsLoaded } from '../stores/plugins';
import { loadPluginCode } from '../utilities/plugins';
+ import type { LayoutData } from './$types';
+
+ export let data: LayoutData;
let pluginsEnabled = env.PUBLIC_TIME_PLUGIN_ENABLED === 'true';
$pluginsLoaded = pluginsEnabled ? false : true;
@@ -25,6 +30,7 @@
beforeNavigate(() => {
// Clear logs on page change
clearLogs();
+ closeCommandPalette();
});
async function loadPlugins() {
@@ -61,6 +67,7 @@
{/if}
+
diff --git a/src/stores/commandPalette.ts b/src/stores/commandPalette.ts
new file mode 100644
index 0000000000..404c85b66b
--- /dev/null
+++ b/src/stores/commandPalette.ts
@@ -0,0 +1,40 @@
+import { writable } from 'svelte/store';
+import type { CommandContext } from '../types/command-palette';
+
+/**
+ * Store to control the command palette visibility
+ */
+export const commandPaletteOpen = writable(false);
+
+/**
+ * Store for the current command context
+ * This is updated by the CommandPaletteProvider component
+ */
+export const commandPaletteContext = writable({
+ model: null,
+ plan: null,
+ route: '',
+ user: null,
+ workspace: null,
+});
+
+/**
+ * Open the command palette
+ */
+export function openCommandPalette(): void {
+ commandPaletteOpen.set(true);
+}
+
+/**
+ * Close the command palette
+ */
+export function closeCommandPalette(): void {
+ commandPaletteOpen.set(false);
+}
+
+/**
+ * Toggle the command palette
+ */
+export function toggleCommandPalette(): void {
+ commandPaletteOpen.update(open => !open);
+}
diff --git a/src/stores/plan.ts b/src/stores/plan.ts
index b29f84709d..48c4c93646 100644
--- a/src/stores/plan.ts
+++ b/src/stores/plan.ts
@@ -113,7 +113,7 @@ export const planRevision = gqlSubscribable(
{ planId },
-1,
null,
- ({ revision }: Pick) => revision,
+ plan => plan?.revision ?? -1,
);
/* Helper Functions. */
diff --git a/src/stores/scheduling.ts b/src/stores/scheduling.ts
index 0b6e389bdf..9eda5ebd86 100644
--- a/src/stores/scheduling.ts
+++ b/src/stores/scheduling.ts
@@ -31,7 +31,7 @@ export const schedulingColumns: Writable = writable('1fr 3px 1fr');
/* Derived. */
-export const selectedSchedulingSpecId = derived(plan, $plan => $plan?.scheduling_specification?.id ?? null);
+export const selectedSchedulingSpecId = derived(plan, $plan => $plan?.scheduling_specification?.id ?? -1);
/* Subscriptions. */
diff --git a/src/types/command-palette.ts b/src/types/command-palette.ts
new file mode 100644
index 0000000000..203877ecb2
--- /dev/null
+++ b/src/types/command-palette.ts
@@ -0,0 +1,104 @@
+import type { Status } from '../enums/status';
+import type { User } from './app';
+import type { ModelWithOwner, PlanWithOwners } from './permissions';
+import type { Plan } from './plan';
+import type { Workspace } from './workspace';
+
+/**
+ * Context available to commands for permission checks and execution.
+ * This context is built from current application state (stores, route, etc.)
+ *
+ * IMPORTANT: All values that affect command enabled state must be included here
+ * to ensure proper reactivity. Do not use get() to read stores inside commands -
+ * instead, add the store value to this context.
+ */
+export interface CommandContext {
+ /** Current constraint check status */
+ constraintsStatus: Status | null;
+ /** Whether scheduling can be run (goals are enabled) */
+ enableScheduling: boolean;
+ /** Whether simulation can be run (plan has changes) */
+ enableSimulation: boolean;
+ /** Full plan object for effects that need it (e.g., simulation, scheduling) */
+ fullPlan: Plan | null;
+ /** Current model (if on a model-specific page) */
+ model?: ModelWithOwner | null;
+ /** Current plan (minimal info for permission checks) */
+ plan?: PlanWithOwners | null;
+ /** Whether the current plan is read-only */
+ planReadOnly: boolean;
+ /** Current route pathname */
+ route: string;
+ /** Current simulation status */
+ simulationStatus: Status | null;
+ /** Current authenticated user */
+ user: User | null;
+ /** Current workspace (if in workspace context) */
+ workspace?: Workspace | null;
+}
+
+/**
+ * Categories for organizing commands in the palette UI.
+ */
+export type CommandCategory =
+ | 'Activity'
+ | 'Constraint'
+ | 'Expansion'
+ | 'External Source'
+ | 'Model'
+ | 'Navigation'
+ | 'Plan'
+ | 'Scheduling'
+ | 'Simulation'
+ | 'View'
+ | 'Workspace';
+
+/**
+ * A command that can be executed from the command palette.
+ * Commands wrap existing effects functions with metadata for display and permission handling.
+ */
+export interface Command {
+ /** Category for grouping commands */
+ category: CommandCategory;
+
+ /**
+ * Execute the command. This typically calls an effect function.
+ */
+ execute: (context: CommandContext) => Promise;
+
+ /**
+ * Returns the reason why the command is disabled, or null if enabled.
+ * This is the single source of truth for enabled state - if this returns null,
+ * the command is enabled. If it returns a string, the command is disabled
+ * and the string explains why.
+ */
+ getDisabledReason: (context: CommandContext) => string | null;
+
+ /** Unique identifier for the command */
+ id: string;
+
+ /**
+ * Whether this command is available in the current context.
+ * Use this for page-specific commands (e.g., "Run Simulation" only on plan pages).
+ * Returns true if the command should appear in the palette.
+ */
+ isAvailable: (context: CommandContext) => boolean;
+
+ /** Optional keywords for fuzzy search (beyond the label) */
+ keywords?: string[];
+
+ /** Display label shown in the palette */
+ label: string;
+
+ /** Optional keyboard shortcut hint to display */
+ shortcut?: () => string;
+}
+
+/**
+ * A command with computed enabled state and disabled reason.
+ * Used by the UI after filtering and processing commands.
+ */
+export interface ProcessedCommand extends Command {
+ disabledReason: string | null;
+ enabled: boolean;
+}
diff --git a/src/utilities/commandRegistry.ts b/src/utilities/commandRegistry.ts
new file mode 100644
index 0000000000..2e0318fa6f
--- /dev/null
+++ b/src/utilities/commandRegistry.ts
@@ -0,0 +1,441 @@
+/**
+ * Command Registry for the Command Palette
+ *
+ * This module provides a centralized registry of commands that can be executed
+ * from the command palette. Commands focus on navigation and actions that can be
+ * performed without complex parameters.
+ *
+ * For actions that require user input (like creating entities), commands navigate
+ * to the appropriate page where the user can complete the action.
+ *
+ * To add a new command:
+ * 1. Import the necessary permission check from './permissions'
+ * 2. Add a new Command object to the `commands` array
+ * 3. Use existing `featurePermissions` for `isEnabled` to avoid duplication
+ * 4. Use `isAvailable` to restrict commands to specific pages/contexts
+ */
+
+import { goto } from '$app/navigation';
+import { base } from '$app/paths';
+import { Status } from '../enums/status';
+import type { Command, CommandContext, ProcessedCommand } from '../types/command-palette';
+import effects from './effects';
+import { isMacOs } from './generic';
+import { featurePermissions } from './permissions';
+
+// Route patterns for context detection
+const PLAN_ROUTES = /\/plans\/\d+/;
+
+/**
+ * Helper to check if route matches a pattern
+ */
+function matchesRoute(route: string, pattern: RegExp): boolean {
+ return pattern.test(route);
+}
+
+/**
+ * All registered commands.
+ * Commands are organized by category for easier maintenance.
+ */
+export const commands: Command[] = [
+ // ============================================
+ // NAVIGATION COMMANDS
+ // ============================================
+ {
+ category: 'Navigation',
+ execute: async () => {
+ await goto(`${base}/plans`);
+ },
+ getDisabledReason: () => null,
+ id: 'nav.plans',
+ isAvailable: () => true,
+ keywords: ['navigate', 'list', 'open'],
+ label: 'Go to Plans',
+ },
+ {
+ category: 'Navigation',
+ execute: async () => {
+ await goto(`${base}/models`);
+ },
+ getDisabledReason: () => null,
+ id: 'nav.models',
+ isAvailable: () => true,
+ keywords: ['navigate', 'mission', 'open'],
+ label: 'Go to Models',
+ },
+ {
+ category: 'Navigation',
+ execute: async () => {
+ await goto(`${base}/constraints`);
+ },
+ getDisabledReason: () => null,
+ id: 'nav.constraints',
+ isAvailable: () => true,
+ keywords: ['navigate', 'open'],
+ label: 'Go to Constraints',
+ },
+ {
+ category: 'Navigation',
+ execute: async () => {
+ await goto(`${base}/scheduling`);
+ },
+ getDisabledReason: () => null,
+ id: 'nav.scheduling',
+ isAvailable: () => true,
+ keywords: ['navigate', 'goals', 'conditions', 'open'],
+ label: 'Go to Scheduling',
+ },
+ {
+ category: 'Navigation',
+ execute: async () => {
+ await goto(`${base}/expansion/rules`);
+ },
+ getDisabledReason: () => null,
+ id: 'nav.expansion',
+ isAvailable: () => true,
+ keywords: ['navigate', 'rules', 'sets', 'open'],
+ label: 'Go to Expansion',
+ },
+ {
+ category: 'Navigation',
+ execute: async () => {
+ await goto(`${base}/workspaces`);
+ },
+ getDisabledReason: () => null,
+ id: 'nav.workspaces',
+ isAvailable: () => true,
+ keywords: ['navigate', 'open'],
+ label: 'Go to Workspaces',
+ },
+ {
+ category: 'Navigation',
+ execute: async () => {
+ await goto(`${base}/external-sources/sources`);
+ },
+ getDisabledReason: () => null,
+ id: 'nav.externalSources',
+ isAvailable: () => true,
+ keywords: ['navigate', 'events', 'open'],
+ label: 'Go to External Sources',
+ },
+ {
+ category: 'Navigation',
+ execute: async () => {
+ await goto(`${base}/dictionaries`);
+ },
+ getDisabledReason: () => null,
+ id: 'nav.dictionaries',
+ isAvailable: () => true,
+ keywords: ['navigate', 'command', 'channel', 'open'],
+ label: 'Go to Dictionaries',
+ },
+
+ // ============================================
+ // PLAN COMMANDS
+ // ============================================
+ {
+ category: 'Plan',
+ execute: async ({ fullPlan, user }) => {
+ if (fullPlan) {
+ await effects.createPlanBranch(fullPlan, user);
+ }
+ },
+ getDisabledReason: ({ model, plan, user }) => {
+ if (!plan) {
+ return 'No plan selected';
+ }
+ if (!model) {
+ return 'No model available';
+ }
+ if (!featurePermissions.planBranch.canCreateBranch(user, plan, model)) {
+ return 'You do not have permission to create a plan branch';
+ }
+ return null;
+ },
+ id: 'plan.createBranch',
+ isAvailable: ({ route }) => matchesRoute(route, PLAN_ROUTES),
+ keywords: ['copy', 'branch', 'clone'],
+ label: 'Create Plan Branch',
+ },
+ {
+ category: 'Plan',
+ execute: async ({ fullPlan, user }) => {
+ if (fullPlan) {
+ await effects.createPlanSnapshot(fullPlan, user);
+ }
+ },
+ getDisabledReason: ({ model, plan, user }) => {
+ if (!plan) {
+ return 'No plan selected';
+ }
+ if (!model) {
+ return 'No model available';
+ }
+ if (!featurePermissions.planSnapshot.canCreate(user, plan, model)) {
+ return 'You do not have permission to create snapshots';
+ }
+ return null;
+ },
+ id: 'plan.createSnapshot',
+ isAvailable: ({ route }) => matchesRoute(route, PLAN_ROUTES),
+ keywords: ['save', 'backup', 'version'],
+ label: 'Create Plan Snapshot',
+ },
+
+ // ============================================
+ // SIMULATION COMMANDS
+ // ============================================
+ {
+ category: 'Simulation',
+ execute: async ({ fullPlan, user }) => {
+ if (fullPlan) {
+ await effects.simulate(fullPlan, false, user);
+ }
+ },
+ getDisabledReason: ({ enableSimulation, model, plan, planReadOnly, user }) => {
+ if (!plan) {
+ return 'No plan selected';
+ }
+ if (!model) {
+ return 'No model available';
+ }
+ if (planReadOnly) {
+ return 'Plan is read-only';
+ }
+ if (!featurePermissions.simulation.canRun(user, plan, model)) {
+ return 'You do not have permission to run simulations';
+ }
+ if (!enableSimulation) {
+ return 'Simulation up-to-date';
+ }
+ return null;
+ },
+ id: 'simulation.run',
+ isAvailable: ({ route }) => matchesRoute(route, PLAN_ROUTES),
+ keywords: ['simulate', 'execute', 'start'],
+ label: 'Run Simulation',
+ shortcut: () => `${isMacOs() ? '⌘' : 'CTRL'}S`,
+ },
+
+ // ============================================
+ // SCHEDULING COMMANDS
+ // ============================================
+ {
+ category: 'Scheduling',
+ execute: async ({ fullPlan, user }) => {
+ if (fullPlan) {
+ await effects.schedule(false, fullPlan, user);
+ }
+ },
+ getDisabledReason: ({ enableScheduling, model, plan, planReadOnly, user }) => {
+ if (!plan) {
+ return 'No plan selected';
+ }
+ if (!model) {
+ return 'No model available';
+ }
+ if (planReadOnly) {
+ return 'Plan is read-only';
+ }
+ if (!featurePermissions.schedulingGoalsPlanSpec.canRun(user, plan, model)) {
+ return 'You do not have permission to run scheduling';
+ }
+ if (!enableScheduling) {
+ return 'No scheduling goals enabled';
+ }
+ return null;
+ },
+ id: 'scheduling.run',
+ isAvailable: ({ route }) => matchesRoute(route, PLAN_ROUTES),
+ keywords: ['schedule', 'goals', 'execute'],
+ label: 'Run Scheduling',
+ },
+ {
+ category: 'Scheduling',
+ execute: async ({ fullPlan, user }) => {
+ if (fullPlan) {
+ await effects.schedule(true, fullPlan, user);
+ }
+ },
+ getDisabledReason: ({ enableScheduling, model, plan, planReadOnly, user }) => {
+ if (!plan) {
+ return 'No plan selected';
+ }
+ if (!model) {
+ return 'No model available';
+ }
+ if (planReadOnly) {
+ return 'Plan is read-only';
+ }
+ if (!featurePermissions.schedulingGoalsPlanSpec.canRun(user, plan, model)) {
+ return 'You do not have permission to run scheduling';
+ }
+ if (!enableScheduling) {
+ return 'No scheduling goals enabled';
+ }
+ return null;
+ },
+ id: 'scheduling.analyze',
+ isAvailable: ({ route }) => matchesRoute(route, PLAN_ROUTES),
+ keywords: ['schedule', 'goals', 'analyze'],
+ label: 'Run Scheduling Analysis',
+ },
+ {
+ category: 'Scheduling',
+ execute: async () => {
+ window.open(`${base}/scheduling/goals/new`, '_blank');
+ },
+ getDisabledReason: ({ user }) =>
+ featurePermissions.schedulingGoals.canCreate(user)
+ ? null
+ : 'You do not have permission to create scheduling goals',
+ id: 'scheduling.newGoal',
+ isAvailable: () => true,
+ keywords: ['create', 'add', 'goal'],
+ label: 'New Scheduling Goal',
+ },
+ {
+ category: 'Scheduling',
+ execute: async () => {
+ window.open(`${base}/scheduling/conditions/new`, '_blank');
+ },
+ getDisabledReason: ({ user }) =>
+ featurePermissions.schedulingConditions.canCreate(user)
+ ? null
+ : 'You do not have permission to create scheduling conditions',
+ id: 'scheduling.newCondition',
+ isAvailable: () => true,
+ keywords: ['create', 'add', 'condition'],
+ label: 'New Scheduling Condition',
+ },
+
+ // ============================================
+ // CONSTRAINT COMMANDS
+ // ============================================
+ {
+ category: 'Constraint',
+ execute: async ({ fullPlan, user }) => {
+ if (fullPlan) {
+ await effects.checkConstraints(fullPlan, user, false);
+ }
+ },
+ getDisabledReason: ({ constraintsStatus, model, plan, simulationStatus, user }) => {
+ if (!plan) {
+ return 'No plan selected';
+ }
+ if (!model) {
+ return 'No model available';
+ }
+ if (!featurePermissions.constraintRuns.canCreate(user, plan, model)) {
+ return 'You do not have permission to check constraints';
+ }
+ if (simulationStatus !== Status.Complete) {
+ return 'Completed simulation required';
+ }
+ if (constraintsStatus === Status.Complete) {
+ return 'Constraints already checked';
+ }
+ return null;
+ },
+ id: 'constraint.check',
+ isAvailable: ({ route }) => matchesRoute(route, PLAN_ROUTES),
+ keywords: ['validate', 'run', 'verify'],
+ label: 'Check Constraints',
+ },
+ {
+ category: 'Constraint',
+ execute: async () => {
+ window.open(`${base}/constraints/new`, '_blank');
+ },
+ getDisabledReason: ({ user }) =>
+ featurePermissions.constraints.canCreate(user) ? null : 'You do not have permission to create constraints',
+ id: 'constraint.new',
+ isAvailable: () => true,
+ keywords: ['create', 'add'],
+ label: 'New Constraint',
+ },
+
+ // ============================================
+ // EXPANSION COMMANDS
+ // ============================================
+ {
+ category: 'Expansion',
+ execute: async () => {
+ window.open(`${base}/expansion/rules/new`, '_blank');
+ },
+ getDisabledReason: ({ user }) =>
+ featurePermissions.expansionRules.canCreate(user) ? null : 'You do not have permission to create expansion rules',
+ id: 'expansion.newRule',
+ isAvailable: () => true,
+ keywords: ['create', 'add'],
+ label: 'New Expansion Rule',
+ },
+ {
+ category: 'Expansion',
+ execute: async () => {
+ window.open(`${base}/expansion/sets/new`, '_blank');
+ },
+ getDisabledReason: ({ user }) =>
+ featurePermissions.expansionRules.canCreate(user) ? null : 'You do not have permission to create expansion sets',
+ id: 'expansion.newSet',
+ isAvailable: () => true,
+ keywords: ['create', 'add'],
+ label: 'New Expansion Set',
+ },
+];
+
+/**
+ * Get all commands filtered by availability and processed with enabled state.
+ * The enabled state is derived from getDisabledReason - if null, the command is enabled.
+ */
+export function getAvailableCommands(context: CommandContext): ProcessedCommand[] {
+ return commands
+ .filter(cmd => cmd.isAvailable(context))
+ .map(cmd => {
+ const disabledReason = cmd.getDisabledReason(context);
+ return {
+ ...cmd,
+ disabledReason,
+ enabled: disabledReason === null,
+ };
+ });
+}
+
+/**
+ * Get a command by its ID.
+ */
+export function getCommandById(id: string): Command | undefined {
+ return commands.find(cmd => cmd.id === id);
+}
+
+/**
+ * Filter commands by search query (matches label and keywords).
+ */
+export function filterCommands(commands: ProcessedCommand[], query: string): ProcessedCommand[] {
+ if (!query.trim()) {
+ return commands;
+ }
+
+ const lowerQuery = query.toLowerCase();
+ return commands.filter(cmd => {
+ const labelMatch = cmd.label.toLowerCase().includes(lowerQuery);
+ const keywordMatch = cmd.keywords?.some(kw => kw.toLowerCase().includes(lowerQuery)) ?? false;
+ const categoryMatch = cmd.category.toLowerCase().includes(lowerQuery);
+ return labelMatch || keywordMatch || categoryMatch;
+ });
+}
+
+/**
+ * Group commands by category.
+ */
+export function groupCommandsByCategory(commands: ProcessedCommand[]): Map {
+ const groups = new Map();
+
+ for (const cmd of commands) {
+ const existing = groups.get(cmd.category) ?? [];
+ existing.push(cmd);
+ groups.set(cmd.category, existing);
+ }
+
+ return groups;
+}