Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Package manager is **pnpm** (`pnpm@10.33.0`), Node **22+**. Use `pnpm`, not `npm
- `dev` is the integration branch. Always create and work from a feature branch off `dev` (never commit directly to `dev` or `main`).
- Use descriptive branch names such as `feat/<scope>`, `fix/<scope>`, or `chore/<scope>`.
- Merge changes into `dev` only via pull requests after required CI checks pass (not into `main`).
- Push branches to `origin` (the `numnx/codeux` fork) and target it for PRs. `upstream` is `codeux-ai/codeux`.
- Open pull requests against `codeux-ai/codeux` with base `dev`.
- Use GitHub CLI (`gh`) for PR workflow when available (for example `gh pr create --base dev`, `gh pr view`, `gh pr merge`).
- PRs should include:
- What changed and why.
Expand Down Expand Up @@ -165,7 +165,7 @@ Release note rules:
- Default working flow for our collaboration:
- Start every change on a new feature branch off `dev`.
- Implement and validate locally (`pnpm run build` minimum; `pnpm run ci` preferred).
- Open a PR into `dev` against `origin` (the `numnx/codeux` fork) using GitHub CLI.
- Open a PR into `codeux-ai/codeux:dev` using GitHub CLI.
- Monitor CI continuously after opening the PR.
- Merge only through PR after all required CI checks pass without errors.
- Delete merged feature branches to keep the branch list clean.
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import type {
GuardrailJobType,
GuardrailJobConfig,
GuardrailOnLimitAction,
PreviewEnvironmentVariable,
SprintPreviewSettings,
SprintPreviewPortMapping,
SprintPreviewSession,
Expand Down Expand Up @@ -247,6 +248,7 @@ export type {
GuardrailJobType,
GuardrailJobConfig,
GuardrailOnLimitAction,
PreviewEnvironmentVariable,
SprintPreviewSettings,
SprintPreviewPortMapping,
SprintPreviewSession,
Expand Down
202 changes: 201 additions & 1 deletion dashboard/src/v2/BrowserPage.tsx

Large diffs are not rendered by default.

104 changes: 104 additions & 0 deletions dashboard/src/v2/components/browser/PreviewEnvironmentEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { FunctionComponent } from "preact";
import { Plus, Trash2 } from "lucide-preact";
import type { PreviewEnvironmentVariable } from "../../../types.js";

const isSecretKey = (key: string): boolean => /(TOKEN|KEY|SECRET|PASSWORD|AUTH|CREDENTIAL)/i.test(key);

const emptyVariable = (): PreviewEnvironmentVariable => ({ key: "", value: "", enabled: true });

export const PreviewEnvironmentEditor: FunctionComponent<{
variables: PreviewEnvironmentVariable[];
onChange: (variables: PreviewEnvironmentVariable[]) => void;
disabled?: boolean;
inheritedVariables?: PreviewEnvironmentVariable[];
addLabel?: string;
valueLabel?: string;
}> = ({
variables,
onChange,
disabled = false,
inheritedVariables = [],
addLabel = "Add variable",
valueLabel = "Environment variable value",
}) => {
const rows = variables.length > 0 ? variables : [];
const updateRow = (index: number, patch: Partial<PreviewEnvironmentVariable>): void => {
onChange(rows.map((row, rowIndex) => rowIndex === index ? { ...row, ...patch } : row));
};

return (
<div className="space-y-3">
{inheritedVariables.length > 0 ? (
<div className="rounded-2xl border border-signal-500/20 bg-signal-500/10 px-3 py-2.5 dark:border-signal-400/20 dark:bg-signal-400/10">
<div className="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-300">Inherited defaults</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{inheritedVariables.filter((variable) => variable.enabled !== false).map((variable) => (
<span key={variable.key} className="rounded-full border border-black/[0.06] bg-white/70 px-2.5 py-1 font-mono text-[10px] font-semibold text-slate-600 dark:border-white/[0.08] dark:bg-white/[0.06] dark:text-slate-300">
{variable.key}={isSecretKey(variable.key) ? "••••" : variable.value || "\"\""}
</span>
))}
</div>
</div>
) : null}

<div className="space-y-2">
{rows.map((variable, index) => {
const valueInputType = isSecretKey(variable.key) ? "password" : "text";
return (
<div key={`env-${index}`} className="grid grid-cols-[auto_minmax(0,1fr)] gap-2 rounded-2xl border border-black/[0.06] bg-white/60 p-3 dark:border-white/[0.08] dark:bg-white/[0.04]">
<label className="flex h-10 items-center" title={variable.enabled === false ? "Variable disabled" : "Variable enabled"}>
<input
type="checkbox"
checked={variable.enabled !== false}
disabled={disabled}
aria-label={`Enable environment variable ${variable.key || index + 1}`}
onChange={(event) => updateRow(index, { enabled: (event.currentTarget as HTMLInputElement).checked })}
className="h-4 w-4 rounded border-slate-300 text-signal-600 focus:ring-signal-500"
/>
</label>
<div className="grid min-w-0 grid-cols-1 gap-2 sm:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)_auto]">
<input
value={variable.key}
disabled={disabled}
placeholder="CODE_UX_ALLOW_PUBLIC_DASHBOARD"
aria-label="Environment variable name"
onInput={(event) => updateRow(index, { key: (event.currentTarget as HTMLInputElement).value })}
className="h-10 min-w-0 rounded-xl border border-black/[0.08] bg-white/80 px-3 font-mono text-xs text-slate-800 outline-none transition focus:border-signal-500/50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/[0.08] dark:bg-void-950 dark:text-slate-100"
/>
<input
type={valueInputType}
value={variable.value}
disabled={disabled}
placeholder="1"
aria-label={valueLabel}
onInput={(event) => updateRow(index, { value: (event.currentTarget as HTMLInputElement).value })}
className="h-10 min-w-0 rounded-xl border border-black/[0.08] bg-white/80 px-3 font-mono text-xs text-slate-800 outline-none transition focus:border-signal-500/50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/[0.08] dark:bg-void-950 dark:text-slate-100"
/>
<button
type="button"
disabled={disabled}
aria-label={`Remove environment variable ${variable.key || index + 1}`}
title="Remove variable"
onClick={() => onChange(rows.filter((_, rowIndex) => rowIndex !== index))}
className="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-black/[0.08] text-slate-500 transition hover:border-status-red/30 hover:bg-status-red/10 hover:text-status-red disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/[0.08]"
>
<Trash2 className="h-4 w-4" strokeWidth={2} />
</button>
</div>
</div>
);
})}
</div>

<button
type="button"
disabled={disabled}
onClick={() => onChange([...rows, emptyVariable()])}
className="inline-flex h-10 items-center gap-2 rounded-2xl border border-black/[0.08] px-3 text-xs font-semibold text-slate-700 transition hover:border-black/[0.16] hover:text-slate-950 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/[0.08] dark:text-slate-200 dark:hover:border-white/[0.16] dark:hover:text-white"
>
<Plus className="h-4 w-4" strokeWidth={2} />
{addLabel}
</button>
</div>
);
};
69 changes: 43 additions & 26 deletions dashboard/src/v2/components/browser/PreviewSessionSlider.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { FunctionComponent } from "preact";
import { useRef } from "preact/hooks";
import { ChevronLeft, ChevronRight, ExternalLink, Globe, Trash2, Loader2, CheckCircle2 } from "lucide-preact";
import { ChevronLeft, ChevronRight, ExternalLink, Globe, Trash2, Loader2, CheckCircle2, SlidersHorizontal } from "lucide-preact";
import type { SprintPreviewSession } from "../../../types.js";
import { buildPreviewOrigin, formatPreviewPortMappingsSummary, getPrimaryPreviewPortMapping } from "../../lib/preview-origin.js";
import { getSafeUrl } from "../../lib/safe-url.js";
Expand All @@ -11,6 +11,7 @@ interface PreviewSessionSliderProps {
selectedSessionId: string | null;
onSelectSession: (id: string) => void;
onRemoveSession: (sessionId: string) => void;
onManageEnvironment: (sessionId: string) => void;
removingSessionIds?: string[];
}

Expand Down Expand Up @@ -55,6 +56,7 @@ export const PreviewSessionSlider: FunctionComponent<PreviewSessionSliderProps>
selectedSessionId,
onSelectSession,
onRemoveSession,
onManageEnvironment,
removingSessionIds = [],
}) => {
const scrollContainerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -214,31 +216,46 @@ export const PreviewSessionSlider: FunctionComponent<PreviewSessionSliderProps>
</button>

<div className="mt-4 flex items-center justify-between gap-2 border-t border-black/[0.06] pt-3 dark:border-white/[0.06]">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
if (!removing) {
onRemoveSession(session.id);
}
}}
className={`inline-flex h-8 items-center justify-center gap-1.5 rounded-xl border px-3 text-[11px] font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-status-red/50 motion-reduce:transition-none ${
removing
? "border-status-red/15 text-status-red cursor-not-allowed disabled:opacity-50"
: "border-status-red/15 text-status-red hover:border-status-red/30 hover:bg-status-red/8"
}`}
style={{ transition: controlTransition }}
title={removing ? removePendingReason : "Remove preview container"}

aria-label={removing ? `Removing preview session ${session.sprintName}` : `Remove preview session ${session.sprintName}`}
disabled={removing}
aria-disabled={removing}
aria-busy={removing}
aria-describedby={removing ? removeDescriptionId : undefined}
>
{removing ? <Loader2 aria-hidden="true" className="h-3 w-3 animate-spin motion-reduce:animate-none" strokeWidth={2.5} /> : <Trash2 aria-hidden="true" className="h-3 w-3" strokeWidth={2.5} />}
{removing ? "Removing..." : "Remove"}
</button>
<div className="flex min-w-0 items-center gap-2">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onManageEnvironment(session.id);
}}
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-xl border border-black/[0.08] px-3 text-[11px] font-semibold text-slate-600 transition hover:border-black/[0.16] hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/50 motion-reduce:transition-none dark:border-white/[0.08] dark:text-slate-300 dark:hover:border-white/[0.16] dark:hover:text-white"
style={{ transition: controlTransition }}
title="Manage container environment overrides"
aria-label={`Manage environment overrides for preview session ${session.sprintName}`}
>
<SlidersHorizontal aria-hidden="true" className="h-3 w-3" strokeWidth={2.5} />
Env
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
if (!removing) {
onRemoveSession(session.id);
}
}}
className={`inline-flex h-8 items-center justify-center gap-1.5 rounded-xl border px-3 text-[11px] font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-status-red/50 motion-reduce:transition-none ${
removing
? "border-status-red/15 text-status-red cursor-not-allowed disabled:opacity-50"
: "border-status-red/15 text-status-red hover:border-status-red/30 hover:bg-status-red/8"
}`}
style={{ transition: controlTransition }}
title={removing ? removePendingReason : "Remove preview container"}
aria-label={removing ? `Removing preview session ${session.sprintName}` : `Remove preview session ${session.sprintName}`}
disabled={removing}
aria-disabled={removing}
aria-busy={removing}
aria-describedby={removing ? removeDescriptionId : undefined}
>
{removing ? <Loader2 aria-hidden="true" className="h-3 w-3 animate-spin motion-reduce:animate-none" strokeWidth={2.5} /> : <Trash2 aria-hidden="true" className="h-3 w-3" strokeWidth={2.5} />}
{removing ? "Removing..." : "Remove"}
</button>
</div>
<a
href={canOpen ? getSafeUrl(origin) : undefined}
target="_blank"
Expand Down
14 changes: 14 additions & 0 deletions dashboard/src/v2/components/settings/ProjectSettingsEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { FunctionComponent, ComponentChildren } from "preact";
import { useId } from "preact/hooks";
import type { ProjectSettings, SettingsValueSource, ThinkingMode } from "../../../types.js";
import { PreviewEnvironmentEditor } from "../browser/PreviewEnvironmentEditor.js";
import { AvantgardeSelect } from "../ui/AvantgardeSelect.js";
import { TextInput, TextAreaInput, NumberInput, SelectInput, Toggle } from "./SettingsFormFields.js";
import {
Expand Down Expand Up @@ -576,6 +577,19 @@ export const ProjectSettingsEditor: FunctionComponent<ProjectSettingsEditorProps
mono
/>
</Row>
<Row label="Default container variables" description="Environment variables injected into every preview container for this scope." badge={getBadge("sprintPreview.environmentVariables")}>
<PreviewEnvironmentEditor
variables={settings.sprintPreview.environmentVariables ?? []}
onChange={(environmentVariables) => update({
sprintPreview: {
...settings.sprintPreview,
environmentVariables,
},
})}
addLabel="Add default"
valueLabel="Preview environment default value"
/>
</Row>
</div>
</Card>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import type { FunctionComponent } from "preact";
import type { SettingsPageState } from "../../../hooks/use-settings-page-state.js";
import { NumberInput, Row, TextInput, Toggle } from "../SettingsFormFields.js";
import { SectionCard, getBadge as getBadgeHelper, getFieldBadge as getFieldBadgeHelper } from "./SharedPanelComponents.js";
import { Eye, Gauge } from "lucide-preact";
import { Eye, Gauge, SlidersHorizontal } from "lucide-preact";
import { PreviewEnvironmentEditor } from "../../browser/PreviewEnvironmentEditor.js";

export const SettingsBrowserPanel: FunctionComponent<{ state: SettingsPageState }> = ({ state }) => {
const {
Expand Down Expand Up @@ -149,6 +150,28 @@ export const SettingsBrowserPanel: FunctionComponent<{ state: SettingsPageState
/>
</Row>
</SectionCard>

<SectionCard title="Preview Environment" watermark="ENV" badge={getBadge("sprintPreview.environmentVariables")} icon={<SlidersHorizontal strokeWidth={2.4} />}>
<Row
label="Default container variables"
description="Environment variables injected into every preview container for this scope. Selected containers can override these from the Browser page."
badge={getFieldBadge("sprintPreview.environmentVariables")}
last
>
<PreviewEnvironmentEditor
variables={editableSettings.sprintPreview.environmentVariables ?? []}
onChange={(environmentVariables) => updateEditableSettings((current) => ({
...current,
sprintPreview: {
...current.sprintPreview,
environmentVariables,
},
}))}
addLabel="Add default"
valueLabel="Preview environment default value"
/>
</Row>
</SectionCard>
</div>
);
};
15 changes: 14 additions & 1 deletion dashboard/src/v2/lib/browser-api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SprintPreviewScript, SprintPreviewSession } from "../../types.js";
import type { PreviewEnvironmentVariable, SprintPreviewScript, SprintPreviewSession } from "../../types.js";
import { fetchJson } from "../../lib/api/fetch-json.js";

export const fetchPreviewSessions = async (projectId: string): Promise<SprintPreviewSession[]> => {
Expand Down Expand Up @@ -54,6 +54,19 @@ export const savePreviewScript = async (
});
};

export const savePreviewEnvironmentOverrides = async (
projectId: string,
sprintId: string,
sessionId: string,
environmentOverrides: PreviewEnvironmentVariable[],
): Promise<SprintPreviewSession> => {
return fetchJson(buildScopedPreviewSessionPath(projectId, sprintId, sessionId, "/environment"), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ environmentOverrides }),
});
};

export const fetchPreviewLogs = async (projectId: string, sprintId: string, sessionId: string, tail = 200): Promise<{ logs: string }> => {
const url = new URL(buildScopedPreviewSessionPath(projectId, sprintId, sessionId, "/logs"), window.location.origin);
url.searchParams.set("tail", String(tail));
Expand Down
16 changes: 16 additions & 0 deletions dashboard/src/v2/lib/settings-api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
EffectiveSettingsResponse,
DesignGuidanceSettings,
PreviewEnvironmentVariable,
ProjectSettings,
SystemSettings,
TechstackSelectionSettings,
Expand Down Expand Up @@ -110,6 +111,21 @@ export const saveProjectSettings = async (projectId: string, settings: ProjectSe
}
};

export const saveProjectPreviewEnvironmentVariables = async (
projectId: string,
environmentVariables: PreviewEnvironmentVariable[],
): Promise<EffectiveSettingsResponse> => {
const effective = await fetchProjectEffectiveSettings(projectId, { cache: "reload" });
await saveProjectSettings(projectId, {
...(effective.settings as ProjectSettings),
sprintPreview: {
...effective.settings.sprintPreview,
environmentVariables,
},
});
return fetchProjectEffectiveSettings(projectId, { cache: "reload" });
};

export const saveProjectTechstackSettings = async (
projectId: string,
techstack: TechstackSelectionSettings,
Expand Down
14 changes: 8 additions & 6 deletions dashboard/src/v2/lib/settings/project-overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ const cloneDesignGuidance = (
cloneDesignGuidanceSettings(designGuidance ?? DEFAULT_DASHBOARD_SETTINGS.designGuidance)
);

const cloneSprintPreviewSettings = (settings: ProjectSettings["sprintPreview"]): ProjectSettings["sprintPreview"] => ({
...settings,
...("containerAppPorts" in settings ? { containerAppPorts: [...(settings.containerAppPorts ?? [])] } : {}),
...("environmentVariables" in settings ? { environmentVariables: (settings.environmentVariables ?? []).map((variable) => ({ ...variable })) } : {}),
});

export const cloneProjectProviders = (
providers: ProjectSettings["aiProvider"]["providers"],
): ProjectSettings["aiProvider"]["providers"] => (
Expand Down Expand Up @@ -201,9 +207,7 @@ export const dashboardSettingsToProjectSettings = (settings: DashboardSettings):
cliWorkflow: {
...settings.cliWorkflow,
},
sprintPreview: {
...settings.sprintPreview,
},
sprintPreview: cloneSprintPreviewSettings(settings.sprintPreview),
workers: {
...settings.workers,
},
Expand Down Expand Up @@ -251,9 +255,7 @@ export const cloneProjectSettings = (settings: ProjectSettings): ProjectSettings
cliWorkflow: {
...settings.cliWorkflow,
},
sprintPreview: {
...settings.sprintPreview,
},
sprintPreview: cloneSprintPreviewSettings(settings.sprintPreview),
workers: {
...settings.workers,
},
Expand Down
Loading