feat(shell): yellow active session, growing composer, phase title, refined activity log, arrow panel toggle
Fix six GUI defects, verified live against the real stack: - NavSessions: mark the in-progress (active) session in yellow (warning token) — both its status dot and its row background. - SteerInput: the composer is now an auto-growing textarea that wraps and grows vertically (caps at 160px, then scrolls); Enter sends, Shift+Enter inserts a newline. - WorkflowBar: show a synthetic title of the current phase under the 8 dots from static EN/IT strings (no LLM); English is displayed to match the chrome. - CentralStatus: reformat the 5-line system-message tail as a structured, monospace list with per-line markers and an emphasized last line. - AppShell + CentralStatus: move the left Model-activity panel toggle off the working spinner (now a pure status indicator) onto a dedicated arrow button (→ opens, ← closes). - gate: rename the phase-confirm button "Conferma e prosegui" → "Salva e procedi" (builders.js + its L1 test). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
|
|||||||
import { StopConfirmDialog } from "./StopConfirmDialog";
|
import { StopConfirmDialog } from "./StopConfirmDialog";
|
||||||
import { SteerInput, ComposerFooter } from "./SteerInput";
|
import { SteerInput, ComposerFooter } from "./SteerInput";
|
||||||
import { WorkflowBar } from "./WorkflowBar";
|
import { WorkflowBar } from "./WorkflowBar";
|
||||||
import { Pencil } from "lucide-react";
|
import { Pencil, ArrowLeft, ArrowRight } from "lucide-react";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Toaster } from "../components/ui/sonner";
|
import { Toaster } from "../components/ui/sonner";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -34,7 +34,7 @@ export function AppShell() {
|
|||||||
const { data: sessions = [] } = useQuery<SessionSummary[]>({
|
const { data: sessions = [] } = useQuery<SessionSummary[]>({
|
||||||
queryKey: ["sessions"], queryFn: listSessions, refetchInterval: 10_000,
|
queryKey: ["sessions"], queryFn: listSessions, refetchInterval: 10_000,
|
||||||
});
|
});
|
||||||
const composerRef = useRef<HTMLInputElement>(null);
|
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [panelSession, setPanelSession] = useState<SessionSummary | null>(null);
|
const [panelSession, setPanelSession] = useState<SessionSummary | null>(null);
|
||||||
@@ -194,7 +194,18 @@ export function AppShell() {
|
|||||||
{/* Conversation column */}
|
{/* Conversation column */}
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
{activeSessionId && (
|
{activeSessionId && (
|
||||||
<div className="shrink-0 border-b border-border/70 bg-background/80 px-6 py-2.5 backdrop-blur">
|
<div className="relative shrink-0 border-b border-border/70 bg-background/80 px-6 py-2.5 backdrop-blur">
|
||||||
|
{/* Arrow toggle for the left Model-activity panel: → opens it, ← closes it. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleActivity}
|
||||||
|
aria-expanded={showActivity}
|
||||||
|
aria-label={showActivity ? "Hide model activity" : "Show model activity"}
|
||||||
|
title={showActivity ? "Hide model activity" : "Show model activity"}
|
||||||
|
className="absolute left-4 top-1/2 grid size-7 -translate-y-1/2 place-items-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
{showActivity ? <ArrowLeft className="size-4" /> : <ArrowRight className="size-4" />}
|
||||||
|
</button>
|
||||||
<div className="mx-auto max-w-3xl">
|
<div className="mx-auto max-w-3xl">
|
||||||
<WorkflowBar
|
<WorkflowBar
|
||||||
finalized={finalized}
|
finalized={finalized}
|
||||||
@@ -209,7 +220,7 @@ export function AppShell() {
|
|||||||
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
||||||
{activeSessionId ? (
|
{activeSessionId ? (
|
||||||
<>
|
<>
|
||||||
<CentralStatus working={working} onOpenActivity={toggleActivity} />
|
<CentralStatus working={working} />
|
||||||
<WidgetHost sessionId={activeSessionId} />
|
<WidgetHost sessionId={activeSessionId} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import { beforeEach } from "vitest";
|
||||||
import { beforeEach, vi } from "vitest";
|
|
||||||
import { useSessionStore } from "../store/sessionStore";
|
import { useSessionStore } from "../store/sessionStore";
|
||||||
import { CentralStatus } from "./CentralStatus";
|
import { CentralStatus } from "./CentralStatus";
|
||||||
|
|
||||||
beforeEach(() => useSessionStore.getState().resetSession());
|
beforeEach(() => useSessionStore.getState().resetSession());
|
||||||
|
|
||||||
test("the working activity icon opens the model-activity panel", async () => {
|
test("the working spinner is a pure status indicator (no activity toggle on it)", () => {
|
||||||
const onOpenActivity = vi.fn();
|
|
||||||
useSessionStore.getState().applyEvent({ type: "text_delta", text: "reading the schema" });
|
useSessionStore.getState().applyEvent({ type: "text_delta", text: "reading the schema" });
|
||||||
render(<CentralStatus working onOpenActivity={onOpenActivity} />);
|
render(<CentralStatus working />);
|
||||||
await userEvent.click(screen.getByRole("button", { name: /model activity/i }));
|
// The spinner no longer opens the panel — that moved to the header arrow toggle.
|
||||||
expect(onOpenActivity).toHaveBeenCalledTimes(1);
|
expect(screen.queryByRole("button", { name: /model activity/i })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders nothing when not working and there is no user entry or step messages", () => {
|
test("renders nothing when not working and there is no user entry or step messages", () => {
|
||||||
|
|||||||
@@ -2,27 +2,22 @@ import { useEffect, useReducer, useState } from "react";
|
|||||||
import { useSessionStore } from "../store/sessionStore";
|
import { useSessionStore } from "../store/sessionStore";
|
||||||
import { WorkingSpinner } from "./WorkingSpinner";
|
import { WorkingSpinner } from "./WorkingSpinner";
|
||||||
|
|
||||||
/** Last few non-blank lines of a string — a compact multi-line "what the model is
|
/** Last few non-blank lines of a string — a compact "what the model is doing now"
|
||||||
* doing now" tail (mirrors ModelActivityPanel's tail-line logic, at a smaller size). */
|
* tail (mirrors ModelActivityPanel's tail-line logic, at a smaller size). Returned
|
||||||
function tailLines(s: string, n = 5): string {
|
* as an array so each line renders as its own formatted log row. */
|
||||||
const lines = s
|
function tailLines(s: string, n = 5): string[] {
|
||||||
|
return s
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((l) => l.trimEnd())
|
.map((l) => l.trimEnd())
|
||||||
.filter((l) => l.trim() !== "");
|
.filter((l) => l.trim() !== "")
|
||||||
return lines.slice(-n).join("\n");
|
.slice(-n);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The minimal central view: the user's last input/choice, a live "working" indicator
|
/** The minimal central view: the user's last input/choice, a live "working" indicator
|
||||||
* (elapsed time + a short tail of the model stream) while the model is busy, and the
|
* (elapsed time + a short tail of the model stream) while the model is busy, and the
|
||||||
* gate's curated messages for the current step. The full verbose stream lives in the
|
* gate's curated messages for the current step. The full verbose stream lives in the
|
||||||
* left Model-activity panel. */
|
* left Model-activity panel. */
|
||||||
export function CentralStatus({
|
export function CentralStatus({ working }: { working: boolean }) {
|
||||||
working,
|
|
||||||
onOpenActivity,
|
|
||||||
}: {
|
|
||||||
working: boolean;
|
|
||||||
onOpenActivity?: () => void;
|
|
||||||
}) {
|
|
||||||
const lastUserEntry = useSessionStore((s) => s.lastUserEntry);
|
const lastUserEntry = useSessionStore((s) => s.lastUserEntry);
|
||||||
const stepMessages = useSessionStore((s) => s.stepMessages);
|
const stepMessages = useSessionStore((s) => s.stepMessages);
|
||||||
const transcript = useSessionStore((s) => s.transcript);
|
const transcript = useSessionStore((s) => s.transcript);
|
||||||
@@ -45,7 +40,7 @@ export function CentralStatus({
|
|||||||
if (!working && !lastUserEntry && stepMessages.length === 0) return null;
|
if (!working && !lastUserEntry && stepMessages.length === 0) return null;
|
||||||
|
|
||||||
const elapsed = startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000)) : 0;
|
const elapsed = startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000)) : 0;
|
||||||
const tail = tailLines(transcript.at(-1)?.text ?? "");
|
const tailRows = tailLines(transcript.at(-1)?.text ?? "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -59,24 +54,42 @@ export function CentralStatus({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{working && (
|
{working && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
|
||||||
<button
|
{/* Pure status indicator — opening the activity panel now lives on the
|
||||||
type="button"
|
dedicated arrow toggle in the header, not on this spinner. */}
|
||||||
onClick={onOpenActivity}
|
<span className="grid size-5 shrink-0 place-items-center text-primary">
|
||||||
aria-label="Show model activity"
|
|
||||||
title="Model activity"
|
|
||||||
className="grid size-5 shrink-0 place-items-center rounded text-primary transition-colors hover:bg-muted"
|
|
||||||
>
|
|
||||||
<WorkingSpinner spinning className="size-3.5" />
|
<WorkingSpinner spinning className="size-3.5" />
|
||||||
</button>
|
</span>
|
||||||
<span className="shrink-0 tabular-nums">{elapsed}s</span>
|
<span className="shrink-0 tabular-nums">{elapsed}s</span>
|
||||||
<span className="sr-only">ThothII is working</span>
|
<span className="sr-only">ThothII is working</span>
|
||||||
</div>
|
</div>
|
||||||
{tail && (
|
{tailRows.length > 0 && (
|
||||||
<p className="line-clamp-5 whitespace-pre-wrap break-words pl-7 text-sm text-muted-foreground/70">
|
<ol className="ml-7 flex flex-col gap-0.5 overflow-hidden rounded-md border border-border/50 bg-muted/40 px-3 py-2">
|
||||||
{tail}
|
{tailRows.map((line, i) => {
|
||||||
</p>
|
const isLast = i === tailRows.length - 1;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className={[
|
||||||
|
"flex items-baseline gap-2 font-mono text-[0.72rem] leading-relaxed",
|
||||||
|
isLast ? "text-foreground/80" : "text-muted-foreground/55",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={[
|
||||||
|
"shrink-0 select-none",
|
||||||
|
isLast ? "text-primary" : "text-muted-foreground/35",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{line}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ export function NavSessions({ sessions, activeSessionId, onOpenPanel, menuFor }:
|
|||||||
}}
|
}}
|
||||||
className={[
|
className={[
|
||||||
"group flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40",
|
"group flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40",
|
||||||
active ? "bg-[oklch(var(--primary)/0.12)]" : "hover:bg-accent",
|
// The in-progress (active) session is marked in yellow — both its dot
|
||||||
|
// and its row background — so it stands out from the rest of the rail.
|
||||||
|
active ? "bg-[oklch(var(--warning)/0.22)]" : "hover:bg-accent",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -48,17 +50,19 @@ export function NavSessions({ sessions, activeSessionId, onOpenPanel, menuFor }:
|
|||||||
title={s.status}
|
title={s.status}
|
||||||
className={[
|
className={[
|
||||||
"size-1.5 shrink-0 rounded-full",
|
"size-1.5 shrink-0 rounded-full",
|
||||||
s.status === "open"
|
active
|
||||||
? "bg-[oklch(var(--success))]"
|
? "bg-[oklch(var(--warning))]"
|
||||||
: s.status === "finalized"
|
: s.status === "open"
|
||||||
? "bg-primary"
|
? "bg-[oklch(var(--success))]"
|
||||||
: "bg-muted-foreground/50",
|
: s.status === "finalized"
|
||||||
|
? "bg-primary"
|
||||||
|
: "bg-muted-foreground/50",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
"min-w-0 flex-1 truncate text-[0.8rem] leading-snug",
|
"min-w-0 flex-1 truncate text-[0.8rem] leading-snug",
|
||||||
active ? "font-semibold text-primary" : "font-medium text-foreground/90",
|
active ? "font-semibold text-foreground" : "font-medium text-foreground/90",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// frontend/src/shell/SteerInput.tsx
|
// frontend/src/shell/SteerInput.tsx
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { CornerDownLeft } from "lucide-react";
|
import { CornerDownLeft } from "lucide-react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { postSteer, createSession } from "../api/sessions";
|
import { postSteer, createSession } from "../api/sessions";
|
||||||
@@ -18,7 +18,7 @@ interface Props {
|
|||||||
/** Interrupt the running session, persisting its state. */
|
/** Interrupt the running session, persisting its state. */
|
||||||
onStop?: () => void;
|
onStop?: () => void;
|
||||||
/** Lets the parent focus the composer (e.g. on "New session"). */
|
/** Lets the parent focus the composer (e.g. on "New session"). */
|
||||||
inputRef?: React.Ref<HTMLInputElement>;
|
inputRef?: React.Ref<HTMLTextAreaElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SteerInput({ sessionId, onSessionCreated, onStop, inputRef }: Props) {
|
export function SteerInput({ sessionId, onSessionCreated, onStop, inputRef }: Props) {
|
||||||
@@ -26,6 +26,23 @@ export function SteerInput({ sessionId, onSessionCreated, onStop, inputRef }: Pr
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const setLastUserEntry = useSessionStore((s) => s.setLastUserEntry);
|
const setLastUserEntry = useSessionStore((s) => s.setLastUserEntry);
|
||||||
const setPhase = useSessionStore((s) => s.setPhase);
|
const setPhase = useSessionStore((s) => s.setPhase);
|
||||||
|
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
// Merge our own ref (for autosizing) with the forwarded one (parent focus).
|
||||||
|
function attachRef(el: HTMLTextAreaElement | null) {
|
||||||
|
taRef.current = el;
|
||||||
|
if (typeof inputRef === "function") inputRef(el);
|
||||||
|
else if (inputRef) (inputRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grow the textarea to fit its content (up to a cap, then it scrolls), and shrink
|
||||||
|
// back when the text is cleared. Runs after every value change so height tracks text.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = taRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
@@ -46,15 +63,16 @@ export function SteerInput({ sessionId, onSessionCreated, onStop, inputRef }: Pr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
if (e.key === "Enter") {
|
// Enter sends; Shift+Enter inserts a newline (the textarea grows to fit it).
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
submit();
|
submit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-end gap-2">
|
||||||
{sessionId && (
|
{sessionId && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -66,15 +84,15 @@ export function SteerInput({ sessionId, onSessionCreated, onStop, inputRef }: Pr
|
|||||||
<span className="block size-2.5 rounded-full bg-primary" />
|
<span className="block size-2.5 rounded-full bg-primary" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<input
|
<textarea
|
||||||
ref={inputRef}
|
ref={attachRef}
|
||||||
type="text"
|
rows={1}
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={sessionId ? "Free steering (!)…" : "Ask a question to start a new session…"}
|
placeholder={sessionId ? "Free steering (!)…" : "Ask a question to start a new session…"}
|
||||||
aria-label={sessionId ? "Steering" : "New question"}
|
aria-label={sessionId ? "Steering" : "New question"}
|
||||||
className="flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground"
|
className="max-h-40 flex-1 resize-none bg-transparent px-1 py-1 text-sm leading-relaxed outline-none placeholder:text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -13,6 +13,21 @@ const PHASES = [
|
|||||||
{ id: "F8", name: "Results" },
|
{ id: "F8", name: "Results" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Synthetic, human-readable title for the current phase, shown under the dots.
|
||||||
|
// Static bilingual strings (no LLM): PHASE_LANG picks which set is displayed.
|
||||||
|
// The rest of the app chrome is English, so the label follows suit.
|
||||||
|
const PHASE_LANG: "en" | "it" = "en";
|
||||||
|
const PHASE_TITLES: Record<string, { en: string; it: string }> = {
|
||||||
|
F1: { en: "Clarifying the question", it: "Chiarimento della domanda" },
|
||||||
|
F2: { en: "Linking the schema", it: "Collegamento dello schema" },
|
||||||
|
F3: { en: "Exploring the data", it: "Esplorazione dei dati" },
|
||||||
|
F4: { en: "Planning the SQL", it: "Pianificazione della query" },
|
||||||
|
F5: { en: "Generating the SQL", it: "Generazione della query" },
|
||||||
|
F6: { en: "Validating the results", it: "Validazione dei risultati" },
|
||||||
|
F7: { en: "Reviewing with you", it: "Revisione con te" },
|
||||||
|
F8: { en: "Presenting results", it: "Presentazione dei risultati" },
|
||||||
|
};
|
||||||
|
|
||||||
type DotState = "done" | "running" | "error" | "pending";
|
type DotState = "done" | "running" | "error" | "pending";
|
||||||
|
|
||||||
// Each state is a tinted chip; red is reserved strictly for error (the GSD theme's
|
// Each state is a tinted chip; red is reserved strictly for error (the GSD theme's
|
||||||
@@ -39,9 +54,13 @@ export function WorkflowBar({
|
|||||||
const currentPhase = useSessionStore((s) => s.currentPhase);
|
const currentPhase = useSessionStore((s) => s.currentPhase);
|
||||||
const phaseError = useSessionStore((s) => s.phaseError);
|
const phaseError = useSessionStore((s) => s.phaseError);
|
||||||
const activeIdx = PHASES.findIndex((p) => p.id === currentPhase);
|
const activeIdx = PHASES.findIndex((p) => p.id === currentPhase);
|
||||||
|
// On a finalized session all phases are done — surface the closing phase's title.
|
||||||
|
const titleId = finalized ? "F8" : currentPhase;
|
||||||
|
const phaseTitle = titleId ? PHASE_TITLES[titleId]?.[PHASE_LANG] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
<nav
|
<nav
|
||||||
aria-label="Workflow progress"
|
aria-label="Workflow progress"
|
||||||
className="flex items-center gap-0 overflow-x-auto py-0.5"
|
className="flex items-center gap-0 overflow-x-auto py-0.5"
|
||||||
@@ -99,6 +118,15 @@ export function WorkflowBar({
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
<ElapsedTimer key={resetKey ?? "none"} running={running} />
|
<ElapsedTimer key={resetKey ?? "none"} running={running} />
|
||||||
|
</div>
|
||||||
|
{phaseTitle && (
|
||||||
|
<span
|
||||||
|
data-testid="phase-title"
|
||||||
|
className="text-[0.72rem] font-medium uppercase tracking-[0.12em] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{phaseTitle}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ test("buildArtifactGate derives approve/reject options from action.kind", () =>
|
|||||||
action: { kind: "approve_reject" },
|
action: { kind: "approve_reject" },
|
||||||
});
|
});
|
||||||
assert.deepEqual(w.options, [
|
assert.deepEqual(w.options, [
|
||||||
{ id: "approve", label: "Conferma e prosegui", recommended: true },
|
{ id: "approve", label: "Salva e procedi", recommended: true },
|
||||||
{ id: "reject", label: "Rifiuta" },
|
{ id: "reject", label: "Rifiuta" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -176,7 +176,7 @@ test("buildArtifactGate: confirm -> single approve; view_only -> no options", ()
|
|||||||
const confirm = buildArtifactGate({
|
const confirm = buildArtifactGate({
|
||||||
id: "u1", phase: "F1", title: "t", artifact: { kind: "phase", data: {} }, action: { kind: "confirm" },
|
id: "u1", phase: "F1", title: "t", artifact: { kind: "phase", data: {} }, action: { kind: "confirm" },
|
||||||
});
|
});
|
||||||
assert.deepEqual(confirm.options, [{ id: "approve", label: "Conferma e prosegui", recommended: true }]);
|
assert.deepEqual(confirm.options, [{ id: "approve", label: "Salva e procedi", recommended: true }]);
|
||||||
const viewOnly = buildArtifactGate({
|
const viewOnly = buildArtifactGate({
|
||||||
id: "u1", phase: "F1", title: "t", artifact: { kind: "phase", data: {} }, action: { kind: "view_only" },
|
id: "u1", phase: "F1", title: "t", artifact: { kind: "phase", data: {} }, action: { kind: "view_only" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -102,10 +102,10 @@ function buildArtifactGate({ id, phase, title, artifact, action }) {
|
|||||||
}
|
}
|
||||||
const ACTION_OPTIONS = {
|
const ACTION_OPTIONS = {
|
||||||
approve_reject: [
|
approve_reject: [
|
||||||
{ id: "approve", label: "Conferma e prosegui", recommended: true },
|
{ id: "approve", label: "Salva e procedi", recommended: true },
|
||||||
{ id: "reject", label: "Rifiuta" },
|
{ id: "reject", label: "Rifiuta" },
|
||||||
],
|
],
|
||||||
confirm: [{ id: "approve", label: "Conferma e prosegui", recommended: true }],
|
confirm: [{ id: "approve", label: "Salva e procedi", recommended: true }],
|
||||||
view_only: [],
|
view_only: [],
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user