diff --git a/backend/app/api/routers/integration_status.py b/backend/app/api/routers/integration_status.py new file mode 100644 index 0000000..b337709 --- /dev/null +++ b/backend/app/api/routers/integration_status.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from typing import Literal + +from fastapi import APIRouter, Depends +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.api.deps import get_db, require_operations_manager +from app.core.config import get_settings +from app.models.outbox import OutboxEvent +from app.schemas import ( + CurrentUser, + IntegrationStatusOut, + McpHubIntegrationStatus, + N8nIntegrationStatus, +) + +router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"]) +settings = get_settings() + + +def _n8n_status(db: Session) -> N8nIntegrationStatus: + counts: dict[str, int] = dict( + db.execute( + select(OutboxEvent.delivery_status, func.count()).group_by(OutboxEvent.delivery_status) + ).all() # type: ignore[arg-type] + ) + pending = counts.get("pending", 0) + delivering = counts.get("delivering", 0) + failed = counts.get("failed", 0) + succeeded = counts.get("succeeded", 0) + + latest_success_at = db.scalar( + select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded") + ) + latest_failure_at = db.scalar( + select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "failed") + ) + + state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"] + if not settings.n8n_dispatch_enabled: + state = "disabled" + elif failed > 0 and succeeded == 0: + state = "unavailable" + elif failed > 0: + state = "degraded" + elif succeeded > 0 or pending > 0 or delivering > 0: + state = "operational" + else: + state = "no_evidence" + + return N8nIntegrationStatus( + configured=bool(settings.n8n_webhook_url), + dispatch_enabled=settings.n8n_dispatch_enabled, + state=state, + pending=pending, + delivering=delivering, + failed=failed, + succeeded=succeeded, + latest_success_at=latest_success_at, + latest_failure_at=latest_failure_at, + ) + + +@router.get("/status", response_model=IntegrationStatusOut) +def integration_status( + db: Session = Depends(get_db), + _user: CurrentUser = Depends(require_operations_manager), +) -> IntegrationStatusOut: + return IntegrationStatusOut( + n8n=_n8n_status(db), + mcp_hub=McpHubIntegrationStatus( + registration_enabled=settings.mcp_hub_registration_enabled, + state="configured" if settings.mcp_hub_registration_enabled else "not_configured", + ), + ) diff --git a/backend/app/api/routers/search.py b/backend/app/api/routers/search.py new file mode 100644 index 0000000..c0b2287 --- /dev/null +++ b/backend/app/api/routers/search.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import or_, select +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user, get_db +from app.models.booking import Booking +from app.models.data_quality import DataQualityIssue +from app.models.vehicle import Vehicle +from app.schemas import CurrentUser, SearchResponse, SearchResultItem + +router = APIRouter(prefix="/api/v1/search", tags=["search"]) + +# Static application sections. Manager-only sections are filtered by role, mirroring the +# same nav visibility rule Layout.tsx applies -- search must never surface a destination +# the current role can't actually reach. +_SECTIONS: list[dict] = [ + { + "label": "Overview", + "detail": "Operations dashboard", + "link": "/dashboard", + "terms": ["overview", "dashboard", "readiness"], + }, + { + "label": "Fleet", + "detail": "Vehicle registry", + "link": "/vehicles", + "terms": ["fleet", "vehicle", "vehicles"], + }, + { + "label": "Bookings", + "detail": "Rental bookings", + "link": "/bookings", + "terms": ["booking", "bookings", "rental"], + }, + { + "label": "Data quality", + "detail": "Quality workbench", + "link": "/data-quality", + "terms": ["quality", "data quality", "issues"], + "role": "operations_manager", + }, + { + "label": "Knowledge", + "detail": "Procedure assistant", + "link": "/knowledge", + "terms": ["knowledge", "procedures"], + }, + { + "label": "Integrations", + "detail": "Automation and integration status", + "link": "/automation", + "terms": ["automation", "integrations", "systems", "n8n"], + "role": "operations_manager", + }, + { + "label": "Audit trail", + "detail": "Audit history", + "link": "/audit", + "terms": ["audit", "history"], + "role": "operations_manager", + }, +] + + +@router.get("", response_model=SearchResponse) +def search( + q: str = Query(min_length=1, max_length=100), + db: Session = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +) -> SearchResponse: + query = q.strip() + normalized = query.lower() + results: list[SearchResultItem] = [] + + for section in _SECTIONS: + role = section.get("role") + if role and user.role != role: + continue + terms: list[str] = section["terms"] + if any(term in normalized or normalized in term for term in terms): + results.append( + SearchResultItem( + type="section", + label=section["label"], + detail=section["detail"], + link=section["link"], + ) + ) + + like = f"%{query}%" + for v in db.scalars( + select(Vehicle) + .where( + or_( + Vehicle.public_ref.ilike(like), + Vehicle.make.ilike(like), + Vehicle.model.ilike(like), + Vehicle.registration_number.ilike(like), + Vehicle.location.ilike(like), + ) + ) + .order_by(Vehicle.public_ref) + .limit(5) + ).all(): + results.append( + SearchResultItem( + type="vehicle", + label=v.public_ref, + detail=f"{v.make} {v.model} · {v.location}", + link=f"/vehicles/{v.public_ref}", + ) + ) + + for b in db.scalars( + select(Booking).where(Booking.public_ref.ilike(like)).order_by(Booking.starts_at.desc()).limit(5) + ).all(): + results.append( + SearchResultItem( + type="booking", + label=b.public_ref, + detail=b.status, + link=f"/bookings/{b.public_ref}", + ) + ) + + # No customer detail route exists in this proof of concept, so customers are + # deliberately never returned here -- there is nowhere useful to send the user. + if user.role == "operations_manager": + for i in db.scalars( + select(DataQualityIssue) + .where(DataQualityIssue.public_ref.ilike(like)) + .order_by(DataQualityIssue.detected_at.desc()) + .limit(5) + ).all(): + results.append( + SearchResultItem( + type="data_quality_issue", + label=i.public_ref, + detail=i.rule_type.replace("_", " "), + link=f"/data-quality/{i.public_ref}", + ) + ) + + return SearchResponse(query=query, results=results[:10]) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e31f5ce..02cf4c3 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -29,6 +29,7 @@ class Settings(BaseSettings): seed_dir: str = "/app/seed" knowledge_dir: str = "/app/knowledge/procedures" mcp_hub_service_token: str = "replace-me-mcp-hub-token" + mcp_hub_registration_enabled: bool = False cors_allow_origins: str = "http://localhost:1228" demo_today: str = "2026-08-01" diff --git a/backend/app/main.py b/backend/app/main.py index 7e11572..6d9d51e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,9 +11,11 @@ from app.api.routers import ( dashboard, data_quality, demo, + integration_status, integrations, knowledge, mcp_integrations, + search, vehicles, workflows, ) @@ -88,3 +90,5 @@ app.include_router(workflows.router) app.include_router(integrations.router) app.include_router(knowledge.router) app.include_router(mcp_integrations.router) +app.include_router(search.router) +app.include_router(integration_status.router) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index e348c56..ad3836b 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -163,6 +163,40 @@ class ApplyRecommendedStatusResult(BaseModel): reason: str +class SearchResultItem(BaseModel): + type: Literal["vehicle", "booking", "data_quality_issue", "section"] + label: str + detail: str + link: str + + +class SearchResponse(BaseModel): + query: str + results: list[SearchResultItem] + + +class N8nIntegrationStatus(BaseModel): + configured: bool + dispatch_enabled: bool + state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"] + pending: int + delivering: int + failed: int + succeeded: int + latest_success_at: datetime | None + latest_failure_at: datetime | None + + +class McpHubIntegrationStatus(BaseModel): + registration_enabled: bool + state: Literal["not_configured", "configured"] + + +class IntegrationStatusOut(BaseModel): + n8n: N8nIntegrationStatus + mcp_hub: McpHubIntegrationStatus + + class VehicleDetailOut(VehicleOut): bookings: list[BookingSummaryOut] = Field(default_factory=list) inspections: list[InspectionOut] = Field(default_factory=list) diff --git a/backend/tests/test_integration_status.py b/backend/tests/test_integration_status.py new file mode 100644 index 0000000..23ac3c9 --- /dev/null +++ b/backend/tests/test_integration_status.py @@ -0,0 +1,41 @@ +def test_integration_status_requires_operations_manager(employee_client): + response = employee_client.get("/api/v1/integrations/status") + assert response.status_code == 403 + + +def test_integration_status_requires_authentication(client): + response = client.get("/api/v1/integrations/status") + assert response.status_code == 401 + + +def test_integration_status_reflects_seeded_mixed_outcomes(ops_client): + response = ops_client.get("/api/v1/integrations/status") + assert response.status_code == 200 + body = response.json() + + n8n = body["n8n"] + assert n8n["dispatch_enabled"] is True + assert n8n["succeeded"] >= 1 + assert n8n["failed"] >= 1 + # The seed deliberately carries both failed and succeeded events, so a single most- + # recent-event read would misreport health -- the aggregate must call this "degraded", + # not "operational" or "unavailable". + assert n8n["state"] == "degraded" + assert n8n["latest_success_at"] is not None + assert n8n["latest_failure_at"] is not None + + mcp_hub = body["mcp_hub"] + assert mcp_hub["registration_enabled"] is False + assert mcp_hub["state"] == "not_configured" + + +def test_integration_status_is_operational_once_all_failed_events_resolved(ops_client): + failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json() + for run in failed: + retried = ops_client.post(f"/api/v1/workflows/{run['event_id']}/retry") + assert retried.status_code == 200 + + response = ops_client.get("/api/v1/integrations/status") + body = response.json()["n8n"] + assert body["failed"] == 0 + assert body["state"] == "operational" diff --git a/backend/tests/test_search.py b/backend/tests/test_search.py new file mode 100644 index 0000000..488a9bf --- /dev/null +++ b/backend/tests/test_search.py @@ -0,0 +1,57 @@ +def test_search_requires_authentication(client): + response = client.get("/api/v1/search", params={"q": "MO-001"}) + assert response.status_code == 401 + + +def test_search_finds_a_vehicle_by_reference(ops_client): + response = ops_client.get("/api/v1/search", params={"q": "MO-001"}) + assert response.status_code == 200 + body = response.json() + match = next((r for r in body["results"] if r["type"] == "vehicle"), None) + assert match is not None + assert match["label"] == "MO-001" + assert match["link"] == "/vehicles/MO-001" + + +def test_search_finds_a_booking_by_reference(ops_client): + response = ops_client.get("/api/v1/search", params={"q": "BK-DEMO-RETURN"}) + assert response.status_code == 200 + match = next((r for r in response.json()["results"] if r["type"] == "booking"), None) + assert match is not None + assert match["link"] == "/bookings/BK-DEMO-RETURN" + + +def test_search_finds_a_data_quality_issue_for_operations_manager(ops_client): + response = ops_client.get("/api/v1/search", params={"q": "DQ-DEMO-OVERLAP"}) + assert response.status_code == 200 + match = next((r for r in response.json()["results"] if r["type"] == "data_quality_issue"), None) + assert match is not None + assert match["link"] == "/data-quality/DQ-DEMO-OVERLAP" + + +def test_search_never_returns_data_quality_issues_for_rental_employee(employee_client): + response = employee_client.get("/api/v1/search", params={"q": "DQ-DEMO-OVERLAP"}) + assert response.status_code == 200 + assert all(r["type"] != "data_quality_issue" for r in response.json()["results"]) + + +def test_search_section_result_visible_to_operations_manager(ops_client): + result = ops_client.get("/api/v1/search", params={"q": "audit"}).json() + assert any(r["type"] == "section" and r["link"] == "/audit" for r in result["results"]) + + +def test_search_section_result_hidden_from_rental_employee(employee_client): + result = employee_client.get("/api/v1/search", params={"q": "audit"}).json() + assert all(r["link"] != "/audit" for r in result["results"]) + + +def test_search_never_returns_customer_results(ops_client): + response = ops_client.get("/api/v1/search", params={"q": "CUS-0012"}) + assert response.status_code == 200 + assert all(r["type"] != "customer" for r in response.json()["results"]) + + +def test_search_no_match_returns_empty_results(ops_client): + response = ops_client.get("/api/v1/search", params={"q": "zzz-no-such-thing-zzz"}) + assert response.status_code == 200 + assert response.json()["results"] == [] diff --git a/frontend/e2e/ui-redesign.spec.ts b/frontend/e2e/ui-redesign.spec.ts index b9c889b..00896f7 100644 --- a/frontend/e2e/ui-redesign.spec.ts +++ b/frontend/e2e/ui-redesign.spec.ts @@ -20,16 +20,50 @@ test("control-centre shell exposes landmarks, persisted readiness and active nav await expect(page.getByRole("link", { name: "Overview" }).first()).toHaveAttribute("aria-current", "page"); }); -test("global search supports its keyboard shortcut and public references", async ({ page }) => { +test("global search supports its keyboard shortcut and finds a vehicle by reference", async ({ page }) => { await page.keyboard.press("Control+k"); - const search = page.getByRole("searchbox", { name: "Search MobilityOps" }); + const search = page.getByRole("combobox", { name: "Search MobilityOps" }); await expect(search).toBeFocused(); await search.fill("MO-024"); - await search.press("Enter"); + const result = page.getByRole("option", { name: /MO-024/ }); + await expect(result).toBeVisible(); + await result.click(); await expect(page).toHaveURL(/\/vehicles\/MO-024$/); await expect(page.getByRole("heading", { name: "MO-024" })).toBeVisible(); }); +test("global search supports arrow-key navigation and Enter to select", async ({ page }) => { + const search = page.getByRole("combobox", { name: "Search MobilityOps" }); + await search.fill("fleet"); + await expect(page.getByRole("option", { name: /Fleet/ })).toBeVisible(); + await search.press("ArrowDown"); + await search.press("Enter"); + await expect(page).toHaveURL(/\/vehicles$/); +}); + +test("global search shows a no-results state and closes on Escape", async ({ page }) => { + const search = page.getByRole("combobox", { name: "Search MobilityOps" }); + await search.fill("zzz-nothing-matches-zzz"); + await expect(page.getByText(/No matches for/)).toBeVisible(); + await search.press("Escape"); + await expect(page.getByRole("listbox")).not.toBeVisible(); +}); + +test("global search finds a booking and a data-quality issue by reference", async ({ page }) => { + const search = page.getByRole("combobox", { name: "Search MobilityOps" }); + await search.fill("BK-DEMO-RETURN"); + const bookingResult = page.getByRole("option", { name: /BK-DEMO-RETURN/ }); + await expect(bookingResult).toBeVisible(); + await bookingResult.click(); + await expect(page).toHaveURL(/\/bookings\/BK-DEMO-RETURN$/); + + await search.fill("DQ-DEMO-OVERLAP"); + const issueResult = page.getByRole("option", { name: /DQ-DEMO-OVERLAP/ }); + await expect(issueResult).toBeVisible(); + await issueResult.click(); + await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-OVERLAP$/); +}); + test("return review separates capture from irreversible commit", async ({ page }) => { await page.goto("/bookings/BK-DEMO-RETURN"); await page.getByLabel("End odometer (km)").fill("60000"); diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index efa5cf9..715e29b 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -180,6 +180,18 @@ export interface ScanResult { created: Record; } +export interface SearchResultItem { + type: "vehicle" | "booking" | "data_quality_issue" | "section"; + label: string; + detail: string; + link: string; +} + +export interface SearchResponse { + query: string; + results: SearchResultItem[]; +} + export interface MergeCustomersRequest { survivor_ref: string; field_overrides?: Record; @@ -218,6 +230,28 @@ export interface KnowledgeHealth { document_count: number; } +export interface N8nIntegrationStatus { + configured: boolean; + dispatch_enabled: boolean; + state: "disabled" | "unavailable" | "degraded" | "operational" | "no_evidence"; + pending: number; + delivering: number; + failed: number; + succeeded: number; + latest_success_at: string | null; + latest_failure_at: string | null; +} + +export interface McpHubIntegrationStatus { + registration_enabled: boolean; + state: "not_configured" | "configured"; +} + +export interface IntegrationStatus { + n8n: N8nIntegrationStatus; + mcp_hub: McpHubIntegrationStatus; +} + export interface AuditEvent { id: string; actor_type: string; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index d99196c..fd109eb 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,9 +1,17 @@ -import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { NavLink, Outlet, useNavigate } from "react-router-dom"; +import { api, ApiError } from "../api/client"; import { useAuth } from "../context/AuthContext"; -import type { Role } from "../api/types"; +import type { Role, SearchResultItem } from "../api/types"; import { BrandMark, Icon, type IconName } from "./Icons"; +const SEARCH_ICON: Record = { + vehicle: "fleet", + booking: "bookings", + data_quality_issue: "quality", + section: "chevron", +}; + interface NavItem { to: string; label: string; @@ -32,23 +40,21 @@ const NAV_GROUPS: Array<{ label: string; items: NavItem[] }> = [ }, ]; -const SEARCH_DESTINATIONS = [ - { to: "/dashboard", terms: ["overview", "dashboard", "readiness"] }, - { to: "/vehicles", terms: ["fleet", "vehicle", "vehicles"] }, - { to: "/bookings", terms: ["booking", "bookings", "rental"] }, - { to: "/data-quality", terms: ["quality", "data quality", "issues"] }, - { to: "/knowledge", terms: ["knowledge", "procedures"] }, - { to: "/automation", terms: ["automation", "integrations", "systems", "n8n"] }, - { to: "/audit", terms: ["audit", "history"] }, -]; - export function Layout() { const { user, logout } = useAuth(); const navigate = useNavigate(); const [mobileOpen, setMobileOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); - const [searchStatus, setSearchStatus] = useState(""); + const [searchOpen, setSearchOpen] = useState(false); + const [searchLoading, setSearchLoading] = useState(false); + const [searchError, setSearchError] = useState(false); + const [searchResults, setSearchResults] = useState([]); + const [activeIndex, setActiveIndex] = useState(-1); + const [resetConfirming, setResetConfirming] = useState(false); + const [resetting, setResetting] = useState(false); + const [resetError, setResetError] = useState(null); const searchInput = useRef(null); + const searchBox = useRef(null); const navGroups = useMemo( () => @@ -72,38 +78,88 @@ export function Layout() { return () => window.removeEventListener("keydown", focusGlobalSearch); }, []); + useEffect(() => { + const query = searchQuery.trim(); + if (!query) { + setSearchResults([]); + setSearchLoading(false); + setSearchError(false); + setActiveIndex(-1); + return; + } + setSearchLoading(true); + setSearchError(false); + const timeout = window.setTimeout(() => { + api + .get<{ query: string; results: SearchResultItem[] }>( + `/api/v1/search?q=${encodeURIComponent(query)}`, + ) + .then((response) => { + setSearchResults(response.results); + setActiveIndex(-1); + }) + .catch(() => setSearchError(true)) + .finally(() => setSearchLoading(false)); + }, 250); + return () => window.clearTimeout(timeout); + }, [searchQuery]); + + useEffect(() => { + function handleOutsideClick(event: MouseEvent) { + if (searchBox.current && !searchBox.current.contains(event.target as Node)) { + setSearchOpen(false); + } + } + document.addEventListener("mousedown", handleOutsideClick); + return () => document.removeEventListener("mousedown", handleOutsideClick); + }, []); + async function handleLogout() { await logout(); navigate("/login"); } - function handleSearch(event: FormEvent) { - event.preventDefault(); - const query = searchQuery.trim(); - if (!query) { - setSearchStatus("Enter a section or a vehicle, booking or issue reference."); + async function handleDemoReset() { + setResetError(null); + setResetting(true); + try { + await api.post("/api/v1/demo/reset"); + // The server invalidates the acting session as part of reset; drop local state the + // same way an explicit logout would and return to the login screen. + await logout(); + navigate("/login"); + } catch (err) { + setResetError(err instanceof ApiError ? err.message : "Could not reset demo data."); + setResetConfirming(false); + } finally { + setResetting(false); + } + } + + function selectResult(item: SearchResultItem) { + setSearchOpen(false); + setSearchQuery(""); + setSearchResults([]); + navigate(item.link); + } + + function handleSearchKeyDown(event: ReactKeyboardEvent) { + if (event.key === "Escape") { + setSearchOpen(false); return; } - const publicRef = query.toUpperCase(); - let destination: string | undefined; - - if (/^MO-\d+$/.test(publicRef)) destination = `/vehicles/${publicRef}`; - else if (/^BK-[A-Z0-9-]+$/.test(publicRef)) destination = `/bookings/${publicRef}`; - else if (/^DQ-[A-Z0-9-]+$/.test(publicRef)) destination = `/data-quality/${publicRef}`; - else { - const normalized = query.toLowerCase(); - destination = SEARCH_DESTINATIONS.find(({ terms }) => - terms.some((term) => term.includes(normalized) || normalized.includes(term)), - )?.to; + if (!searchOpen || searchResults.length === 0) return; + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((i) => (i + 1) % searchResults.length); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex((i) => (i <= 0 ? searchResults.length - 1 : i - 1)); + } else if (event.key === "Enter") { + event.preventDefault(); + const target = searchResults[activeIndex] ?? searchResults[0]; + if (target) selectResult(target); } - - if (destination) { - setSearchStatus(""); - navigate(destination); - return; - } - - setSearchStatus(`No destination found for ${query}. Try a vehicle, booking or issue reference.`); } return ( @@ -136,6 +192,26 @@ export function Layout() {
Demo environmentSynthetic data only
+ {user?.role === "operations_manager" && ( +
+ {resetError &&

{resetError}

} + {!resetConfirming ? ( + + ) : ( +
+

All synthetic changes will be discarded and deterministic demo data restored. You will be signed out.

+ + +
+ )} +
+ )} {mobileOpen && -
+
= 0 ? `search-result-${activeIndex}` : undefined} value={searchQuery} placeholder="Search fleet, booking or section…" - aria-describedby="global-search-status" + onFocus={() => setSearchOpen(true)} onChange={(event) => { setSearchQuery(event.target.value); - setSearchStatus(""); + setSearchOpen(true); }} + onKeyDown={handleSearchKeyDown} /> Ctrl K - {searchStatus} - + {searchOpen && searchQuery.trim() && ( +
+ {searchLoading &&

Searching…

} + {!searchLoading && searchError &&

Search is unavailable right now.

} + {!searchLoading && !searchError && searchResults.length === 0 && ( +

No matches for "{searchQuery.trim()}".

+ )} + {!searchLoading && + !searchError && + searchResults.map((item, index) => ( + + ))} +
+ )} +
Europe/Brussels {user && ( diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 3e56709..a38409a 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -70,15 +70,27 @@ a:hover { color: var(--teal); } .sidebar-foot div { display: grid; gap: 2px; } .sidebar-foot strong { font-size: .72rem; color: #dbe5ef; } .sidebar-foot span:not(.environment-dot) { font-size: .66rem; color: #7f8da1; } +.sidebar-reset { margin: 0 12px 12px; display: grid; gap: 8px; } +.sidebar-reset .button { width: 100%; } +.sidebar-reset .confirm-bar { background: var(--surface); border-radius: var(--radius); padding: 10px; display: grid; gap: 8px; } +.sidebar-reset .confirm-bar p { margin: 0; font-size: .7rem; color: var(--ink-soft); } .environment-dot, .live-indicator { width: 8px; height: 8px; border-radius: 50%; background: #2dd4bf; box-shadow: 0 0 0 4px rgba(45, 212, 191, .12); } .app-workspace { grid-column: 2; min-width: 0; min-height: 100vh; display: flex; flex-direction: column; } .topbar { height: 64px; display: flex; align-items: center; gap: 20px; padding: 0 28px; background: rgba(255,255,255,.97); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; } -.global-search { width: min(410px, 42vw); min-height: 38px; display: flex; align-items: center; gap: 9px; padding: 0 10px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); color: var(--muted); } +.global-search { position: relative; width: min(410px, 42vw); min-height: 38px; display: flex; align-items: center; gap: 9px; padding: 0 10px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); color: var(--muted); } .global-search svg { width: 16px; } .global-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--ink); font-size: .8rem; } .global-search:focus-within { border-color: var(--teal); box-shadow: 0 0 0 3px rgba(18, 132, 126, .14); } .global-search kbd { padding: 2px 5px; border: 1px solid var(--line); background: white; color: var(--muted); font-size: .64rem; border-radius: 3px; } +.search-results { position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 30; max-height: 60vh; overflow-y: auto; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-float); padding: 6px; } +.search-status { margin: 0; padding: 10px 8px; font-size: .78rem; color: var(--muted); } +.search-result { width: 100%; display: flex; align-items: center; gap: 10px; padding: 8px; border: 0; border-radius: var(--radius); background: transparent; color: var(--ink); text-align: left; cursor: pointer; font: inherit; } +.search-result svg { width: 16px; flex-shrink: 0; color: var(--muted); } +.search-result:hover, .search-result.is-active { background: var(--surface-subtle); } +.search-result-copy { display: flex; flex-direction: column; min-width: 0; } +.search-result-copy strong { font-size: .82rem; } +.search-result-copy small { color: var(--muted); font-size: .72rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .topbar-meta { margin-left: auto; display: flex; align-items: center; gap: 18px; } .timezone { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: .72rem; white-space: nowrap; } .timezone svg { width: 15px; }