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
21 changes: 21 additions & 0 deletions web/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
testDir: './tests/e2e',
fullyParallel: false,
retries: 0,
workers: 1,
reporter: [['list']],
use: {
headless: true,
actionTimeout: 15_000,
navigationTimeout: 30_000,
ignoreHTTPSErrors: true,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
10 changes: 9 additions & 1 deletion web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useApprovalsQueue } from '@/state/useApprovalsQueue';
import { useAgentDefinitions } from '@/state/useAgentDefinitions';
import { useSessionFull } from '@/state/useSessionFull';
import { MonitorRail } from '@/monitors/MonitorRail';
import { NewSessionModal } from '@/modals/NewSessionModal';

const UI_VERSION = 'v2.0.0-rc1';
const RUNTIME_VERSION_FALLBACK = 'unknown';
Expand All @@ -32,6 +33,7 @@ const paneStyle: CSSProperties = {

export function App() {
const [activeSid, setActiveSid] = useState<string | null>(null);
const [newSessionOpen, setNewSessionOpen] = useState(false);

const uiHints = useUiHints();
const sessionList = useSessionList();
Expand Down Expand Up @@ -65,7 +67,7 @@ export function App() {
health={health}
approvalsCount={approvals.count}
onSearch={() => {/* Phase 6: open search overlay */}}
onNew={() => {/* Phase 6: open NewSessionModal */}}
onNew={() => setNewSessionOpen(true)}
onApprovalsClick={() => {/* Phase 6: open approvals view */}}
/>
<FlowStrip
Expand Down Expand Up @@ -98,6 +100,12 @@ export function App() {
runtimeVersion={RUNTIME_VERSION_FALLBACK}
uiVersion={UI_VERSION}
/>
<NewSessionModal
open={newSessionOpen}
onOpenChange={setNewSessionOpen}
environments={uiHints.data?.environments ?? ['dev']}
onCreated={(sid) => setActiveSid(sid)}
/>
</div>
);
}
153 changes: 153 additions & 0 deletions web/src/modals/NewSessionModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { useState, useEffect } from 'react';
import type { CSSProperties } from 'react';
import { Modal } from '@/components/Modal';
import { apiFetch, ApiClientError } from '@/api/client';

interface NewSessionModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
environments: string[];
onCreated: (sid: string) => void;
}

interface StartResponse {
session_id: string;
}

const labelStyle: CSSProperties = {
display: 'block',
fontFamily: 'var(--ff-mono)',
fontSize: 10,
color: 'var(--ink-3)',
letterSpacing: '0.14em',
textTransform: 'uppercase',
marginBottom: 4,
};

const fieldWrap: CSSProperties = { marginBottom: 16 };

const inputStyle: CSSProperties = {
width: '100%',
height: 32,
padding: '0 10px',
fontFamily: 'var(--ff-sans)',
fontSize: 13,
color: 'var(--ink-1)',
background: 'var(--bg-elev)',
border: '1px solid var(--hair)',
borderRadius: 0,
outline: 'none',
boxSizing: 'border-box',
};

const textareaStyle: CSSProperties = {
...inputStyle,
height: 'auto',
minHeight: 96,
padding: '8px 10px',
resize: 'vertical',
fontFamily: 'var(--ff-sans)',
lineHeight: 1.5,
};

const selectStyle: CSSProperties = {
...inputStyle,
height: 32,
appearance: 'auto',
};

export function NewSessionModal({ open, onOpenChange, environments, onCreated }: NewSessionModalProps) {
const [query, setQuery] = useState('');
const [environment, setEnvironment] = useState(environments[0] ?? 'dev');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (!open) {
setQuery('');
setError(null);
setSubmitting(false);
setEnvironment(environments[0] ?? 'dev');
}
}, [open, environments]);

const canSubmit = query.trim().length > 0 && !submitting;

async function handleSubmit() {
if (!canSubmit) return;
setSubmitting(true);
setError(null);
try {
const res = await apiFetch<StartResponse>('/sessions', {
method: 'POST',
json: { query: query.trim(), environment, submitter: { id: 'operator' } },
});
onCreated(res.session_id);
onOpenChange(false);
} catch (e) {
if (e instanceof ApiClientError) {
setError(`${e.code}: ${e.message}`);
} else {
setError(String(e));
}
setSubmitting(false);
}
}

return (
<Modal
open={open}
onOpenChange={onOpenChange}
eyebrow="NEW SESSION"
title="Start a new session"
primaryAction={{
label: submitting ? 'Starting…' : 'Create session',
onClick: () => { void handleSubmit(); },
disabled: !canSubmit,
}}
>
<div style={fieldWrap}>
<label style={labelStyle} htmlFor="ns-query">Query</label>
<textarea
id="ns-query"
autoFocus
placeholder="Describe the incident or task in plain language…"
value={query}
onChange={(e) => setQuery(e.target.value)}
style={textareaStyle}
disabled={submitting}
/>
</div>
<div style={fieldWrap}>
<label style={labelStyle} htmlFor="ns-env">Environment</label>
<select
id="ns-env"
value={environment}
onChange={(e) => setEnvironment(e.target.value)}
style={selectStyle}
disabled={submitting}
>
{environments.map((env) => (
<option key={env} value={env}>{env}</option>
))}
</select>
</div>
{error && (
<div
role="alert"
style={{
marginTop: 12,
padding: '8px 10px',
fontFamily: 'var(--ff-mono)',
fontSize: 11,
color: 'var(--danger)',
background: 'var(--danger-bg)',
border: '1px solid var(--danger)',
}}
>
{error}
</div>
)}
</Modal>
);
}
41 changes: 41 additions & 0 deletions web/tests/e2e/new-session.live.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { test, expect } from '@playwright/test';

const BASE = process.env.E2E_BASE_URL ?? 'https://clm.randomcodespace.dev';

test('shell renders + New Session creates a INC- id and opens canvas', async ({ page }) => {
test.setTimeout(60_000);

page.on('console', (msg) => {
if (msg.type() === 'error') console.log('[browser-err]', msg.text());
});
page.on('pageerror', (err) => console.log('[page-err]', err.message));

await page.goto(BASE);

// Shell renders
await expect(page.getByText(/Sessions/i).first()).toBeVisible();
await expect(page.getByText(/All Systems Normal|Degraded|Critical/i)).toBeVisible();
await expect(page.getByText(/Select a session/i)).toBeVisible();

// Open the modal
await page.getByRole('button', { name: /New Session/i }).click();
await expect(page.getByText(/Start a new session/i)).toBeVisible();

// Fill + submit
const query = `e2e smoke ${Date.now()}: high p99 on payments-svc`;
await page.locator('#ns-query').fill(query);
await page.getByRole('button', { name: /Create session/i }).click();

// Modal closes
await expect(page.getByText(/Start a new session/i)).toBeHidden({ timeout: 30_000 });

// Canvas shows the new session id (backend uses INC-YYYYMMDD-NNN form)
const sidLocator = page.getByText(/INC-\d{4,}/).first();
await expect(sidLocator).toBeVisible({ timeout: 30_000 });
const sid = (await sidLocator.textContent()) ?? '';
expect(sid).toMatch(/INC-\d{4,}/);

// Canvas head renders meta row (env + turns counters)
await expect(page.getByText(/ENV /)).toBeVisible();
await expect(page.getByText(/TURNS /)).toBeVisible();
});
Loading