summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/components')
-rw-r--r--makima/frontend/src/components/NavStrip.tsx27
-rw-r--r--makima/frontend/src/components/directives/DocumentEditor.tsx152
2 files changed, 144 insertions, 35 deletions
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx
index 17013ac..a6e483d 100644
--- a/makima/frontend/src/components/NavStrip.tsx
+++ b/makima/frontend/src/components/NavStrip.tsx
@@ -1,5 +1,6 @@
import { useAuth } from "../contexts/AuthContext";
import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext";
+import { useUserSettings } from "../hooks/useUserSettings";
import { RewriteLink } from "./RewriteLink";
interface NavLink {
@@ -7,14 +8,30 @@ interface NavLink {
href: string;
requiresAuth?: boolean;
external?: boolean;
+ /**
+ * When true the link is hidden once the user has flipped on the
+ * document-mode UI — those areas (Exec, Contracts) are subsumed by the
+ * directive-document interface and surfacing them just creates noise.
+ */
+ hideInDocumentMode?: boolean;
}
const NAV_LINKS: NavLink[] = [
{ label: "Listen", href: "/listen" },
{ label: "Directives", href: "/directives", requiresAuth: true },
{ label: "Orders", href: "/orders", requiresAuth: true },
- { label: "Contracts", href: "/contracts", requiresAuth: true },
- { label: "Exec", href: "/exec", requiresAuth: true },
+ {
+ label: "Contracts",
+ href: "/contracts",
+ requiresAuth: true,
+ hideInDocumentMode: true,
+ },
+ {
+ label: "Exec",
+ href: "/exec",
+ requiresAuth: true,
+ hideInDocumentMode: true,
+ },
{ label: "Daemons", href: "/daemons", requiresAuth: true },
{ label: "History", href: "/history", requiresAuth: true },
];
@@ -22,6 +39,8 @@ const NAV_LINKS: NavLink[] = [
export function NavStrip() {
const { isAuthenticated, isAuthConfigured, signOut, user } = useAuth();
const { pendingQuestions } = useSupervisorQuestions();
+ const { settings } = useUserSettings();
+ const documentMode = settings?.documentModeEnabled ?? false;
const directiveQuestionCount = pendingQuestions.filter(q => q.directiveId).length;
const handleSignOut = async () => {
@@ -41,7 +60,9 @@ export function NavStrip() {
NAV//
</span>
<div className="flex flex-wrap gap-2 items-center flex-1">
- {NAV_LINKS.map((link) => (
+ {NAV_LINKS.filter(
+ (link) => !(documentMode && link.hideInDocumentMode),
+ ).map((link) => (
<span key={link.label} className="relative inline-flex items-center">
<RewriteLink
to={link.href}
diff --git a/makima/frontend/src/components/directives/DocumentEditor.tsx b/makima/frontend/src/components/directives/DocumentEditor.tsx
index 270f5c3..3dd8522 100644
--- a/makima/frontend/src/components/directives/DocumentEditor.tsx
+++ b/makima/frontend/src/components/directives/DocumentEditor.tsx
@@ -440,6 +440,23 @@ function CountdownKeyBridge({
return null;
}
+/**
+ * Render a "Draft saved Ns ago" label that ticks once per second. Returns
+ * null when the timestamp is older than 60 seconds (clutter-management).
+ */
+function useDraftFreshnessLabel(draftSavedAt: number | null): string | null {
+ const [now, setNow] = useState(() => Date.now());
+ useEffect(() => {
+ const id = window.setInterval(() => setNow(Date.now()), 1000);
+ return () => window.clearInterval(id);
+ }, []);
+ if (draftSavedAt == null) return null;
+ const ageSec = Math.max(0, Math.floor((now - draftSavedAt) / 1000));
+ if (ageSec > 60) return null;
+ if (ageSec < 2) return "Draft saved";
+ return `Draft saved ${ageSec}s ago`;
+}
+
// =============================================================================
// Floating formatting toolbar
//
@@ -669,6 +686,7 @@ interface SaveCountdownBarProps {
remainingMs: number;
liveStart: boolean;
orchestratorRunning: boolean;
+ draftSavedAt: number | null;
onSaveNow: () => void;
onCancel: () => void;
onToggleLiveStart: (next: boolean) => void;
@@ -679,22 +697,14 @@ function SaveCountdownBar({
remainingMs,
liveStart,
orchestratorRunning,
+ draftSavedAt,
onSaveNow,
onCancel,
onToggleLiveStart,
}: SaveCountdownBarProps) {
- // Visibility rules:
- // - Always show when actually saving / saved / error (transient feedback).
- // - Show when "dirty" if live-start is OFF (user must trigger save).
- // - Show when "pending" only inside the last BAR_VISIBLE_MS so the user
- // does not feel rushed during the long fresh countdown.
- const visible =
- state === "saving" ||
- state === "saved" ||
- state === "error" ||
- (state === "dirty" && !liveStart) ||
- (state === "pending" && remainingMs <= BAR_VISIBLE_MS);
- if (!visible) return null;
+ // The bar is now ALWAYS visible. Users explicitly asked to be able to
+ // observe save state at all times — and to have a "Save now" button they
+ // can hit without waiting for the countdown.
let label: string;
let progressPct = 0;
@@ -702,13 +712,19 @@ function SaveCountdownBar({
if (state === "pending") {
const seconds = Math.max(0, Math.ceil(remainingMs / 1000));
- label = orchestratorRunning
- ? `Replanning in ${seconds}s — Esc/Undo cancels.`
- : `Saving goal in ${seconds}s — Esc/Undo cancels.`;
- progressPct = Math.max(
- 0,
- Math.min(100, (1 - remainingMs / BAR_VISIBLE_MS) * 100),
- );
+ // Show ticking countdown in the last 10s, otherwise a quieter label.
+ if (remainingMs <= BAR_VISIBLE_MS) {
+ label = orchestratorRunning
+ ? `Replanning in ${seconds}s — Esc/Undo cancels.`
+ : `Saving in ${seconds}s — Esc/Undo cancels.`;
+ progressPct = Math.max(
+ 0,
+ Math.min(100, (1 - remainingMs / BAR_VISIBLE_MS) * 100),
+ );
+ } else {
+ label = "Unsaved changes — auto-save soon.";
+ progressPct = 0;
+ }
} else if (state === "dirty") {
label = orchestratorRunning
? "Unsaved changes — saving will replan the directive."
@@ -722,12 +738,23 @@ function SaveCountdownBar({
label = "Saved";
progressPct = 100;
tone = "border-emerald-700 text-emerald-300";
- } else {
+ } else if (state === "error") {
label = "Save failed — try again.";
progressPct = 100;
tone = "border-red-700 text-red-300";
+ } else {
+ label = "Up to date.";
+ progressPct = 0;
+ tone = "border-[rgba(117,170,252,0.2)] text-[#7788aa]";
}
+ // Right-side "Draft saved Xs ago" stamp — re-renders on a 1Hz ticker so
+ // the user can see drafts being captured. We only ever surface this when
+ // a write has happened in the last minute; otherwise we hide it.
+ const draftLabel = useDraftFreshnessLabel(draftSavedAt);
+
+ const dirtyish = state === "dirty" || state === "pending";
+
return (
<div
className={`shrink-0 border-t border-dashed ${tone} bg-[#0a1628]`}
@@ -742,6 +769,15 @@ function SaveCountdownBar({
<div className="flex items-center gap-3 px-4 py-1.5">
<span className="text-[10px] font-mono flex-1 truncate">{label}</span>
+ {draftLabel && (
+ <span
+ className="text-[10px] font-mono text-[#556677] shrink-0"
+ title="Drafts auto-save to this device on every keystroke"
+ >
+ {draftLabel}
+ </span>
+ )}
+
{/* Live-start toggle is always shown so users can flip it from the bar. */}
<label className="flex items-center gap-1.5 text-[10px] font-mono text-[#7788aa] cursor-pointer select-none shrink-0">
<input
@@ -753,16 +789,17 @@ function SaveCountdownBar({
<span>Live start</span>
</label>
- {(state === "dirty" || state === "pending") && (
- <button
- type="button"
- onClick={onSaveNow}
- className="text-[10px] font-mono text-emerald-300 hover:text-white border border-emerald-700/60 rounded px-2 py-0.5 shrink-0"
- >
- Save now
- </button>
- )}
- {(state === "dirty" || state === "pending") && (
+ {/* "Save now" is always available when there are unsaved edits, so
+ users don't have to wait for the auto-save countdown. */}
+ <button
+ type="button"
+ onClick={onSaveNow}
+ disabled={!dirtyish}
+ className="text-[10px] font-mono text-emerald-300 hover:text-white disabled:text-[#445566] disabled:cursor-not-allowed border border-emerald-700/60 disabled:border-[#1f2a3a] rounded px-2 py-0.5 shrink-0"
+ >
+ Save now
+ </button>
+ {dirtyish && (
<button
type="button"
onClick={onCancel}
@@ -831,10 +868,20 @@ export function DocumentEditor({
const [saveState, setSaveState] = useState<SaveState>("idle");
const [remainingMs, setRemainingMs] = useState(countdownMs);
const pendingGoalRef = useRef<string>(directive.goal);
+ const persistedGoalRef = useRef<string>(directive.goal);
const timerRef = useRef<number | null>(null);
const tickRef = useRef<number | null>(null);
const deadlineRef = useRef<number>(0);
const editorRef = useRef<LexicalEditor | null>(null);
+ // Timestamp of the most recent localStorage draft write — drives the
+ // "Draft saved Xs ago" indicator so users can SEE that drafts are working.
+ const [draftSavedAt, setDraftSavedAt] = useState<number | null>(null);
+
+ // Track the persisted goal in a ref so beforeunload handlers can do their
+ // own freshness comparison without a stale closure.
+ useEffect(() => {
+ persistedGoalRef.current = directive.goal;
+ }, [directive.goal]);
function cancelTimers() {
if (timerRef.current != null) {
@@ -957,6 +1004,44 @@ export function DocumentEditor({
};
}, []);
+ // Belt-and-braces draft persistence: even though we write synchronously on
+ // every keystroke, browsers can swallow the very last edit if the user hits
+ // a hard close (tab close, browser quit, mobile background) before React
+ // processes the keystroke. These handlers flush whatever is in pendingGoalRef
+ // straight to localStorage on every "we're about to be paused" signal.
+ useEffect(() => {
+ const flush = () => {
+ try {
+ const value = pendingGoalRef.current;
+ const persisted = persistedGoalRef.current;
+ const key = DRAFT_KEY(directive.id);
+ if (value === persisted) {
+ window.localStorage.removeItem(key);
+ } else {
+ window.localStorage.setItem(key, value);
+ }
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.warn("[makima] flush handler failed to persist draft", err);
+ }
+ };
+ const onBeforeUnload = () => flush();
+ const onPageHide = () => flush();
+ const onVisibility = () => {
+ if (document.visibilityState === "hidden") flush();
+ };
+ window.addEventListener("beforeunload", onBeforeUnload);
+ window.addEventListener("pagehide", onPageHide);
+ document.addEventListener("visibilitychange", onVisibility);
+ return () => {
+ window.removeEventListener("beforeunload", onBeforeUnload);
+ window.removeEventListener("pagehide", onPageHide);
+ document.removeEventListener("visibilitychange", onVisibility);
+ // Final flush on React unmount (route navigation within the SPA).
+ flush();
+ };
+ }, [directive.id]);
+
const handleGoalChange = useCallback(
(goal: string) => {
pendingGoalRef.current = goal;
@@ -971,9 +1056,11 @@ export function DocumentEditor({
window.localStorage.removeItem(DRAFT_KEY(directive.id));
} else {
window.localStorage.setItem(DRAFT_KEY(directive.id), goal);
+ setDraftSavedAt(Date.now());
}
- } catch {
- /* localStorage may be unavailable / full; ignore */
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.warn("[makima] failed to persist draft", err);
}
// 2. State-machine.
@@ -1063,6 +1150,7 @@ export function DocumentEditor({
remainingMs={remainingMs}
liveStart={liveStart}
orchestratorRunning={orchestratorRunning}
+ draftSavedAt={draftSavedAt}
onSaveNow={() => void fireSave()}
onCancel={cancelCountdown}
onToggleLiveStart={toggleLiveStart}