Skip to content

Commit 3a94c9e

Browse files
committed
feat: add last-updated timestamps to Brain page sections
1 parent 88a53b2 commit 3a94c9e

File tree

3 files changed

+260
-4
lines changed

3 files changed

+260
-4
lines changed

apps/desktop/src/features/brain/components/BrainWorkspace.tsx

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,14 @@ import { useCallback, useEffect, useRef, useState } from "react";
2424
import Markdown from "react-markdown";
2525
import remarkGfm from "remark-gfm";
2626
import type { SidecarClient } from "@/lib/sidecar/client";
27+
import { formatRelativeTime } from "@/lib/utils/output-labels";
2728
import { isRefinableSection } from "@/features/brain/lib/refine-context-prompt";
29+
import {
30+
getSectionTimestamp,
31+
setSectionTimestamp,
32+
setContentHash,
33+
hasContentChanged,
34+
} from "@/features/brain/lib/brain-section-timestamps";
2835
import { OperatingMemorySection } from "./OperatingMemorySection";
2936
import { SpecialistContextSection } from "./SpecialistContextSection";
3037

@@ -395,6 +402,7 @@ function BrainEditor({
395402
const [isEditing, setIsEditing] = useState(false);
396403
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved">("idle");
397404
const [fileExists, setFileExists] = useState(false);
405+
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
398406
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
399407
const savedTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
400408
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -419,6 +427,21 @@ function BrainEditor({
419427
}
420428
}
421429

430+
// Detect external changes (e.g. from Refine context) and update timestamp
431+
if (result.exists && loaded) {
432+
if (hasContentChanged(agentId, section.filename, loaded)) {
433+
const existing = getSectionTimestamp(agentId, section.filename);
434+
if (existing) {
435+
// Content changed externally — update timestamp
436+
setSectionTimestamp(agentId, section.filename);
437+
}
438+
setContentHash(agentId, section.filename, loaded);
439+
}
440+
setLastUpdated(getSectionTimestamp(agentId, section.filename));
441+
} else {
442+
setLastUpdated(null);
443+
}
444+
422445
setContent(loaded);
423446
setFileExists(result.exists);
424447
setIsLoading(false);
@@ -427,12 +450,13 @@ function BrainEditor({
427450
if (cancelled) return;
428451
setContent("");
429452
setFileExists(false);
453+
setLastUpdated(null);
430454
setIsLoading(false);
431455
},
432456
);
433457

434458
return () => { cancelled = true; };
435-
}, [agentId, client, section.filename]);
459+
}, [agentId, client, section.filename, section.id]);
436460

437461
// Focus textarea when entering edit mode
438462
useEffect(() => {
@@ -453,6 +477,9 @@ function BrainEditor({
453477
() => {
454478
setFileExists(true);
455479
setSaveState("saved");
480+
setSectionTimestamp(agentId, section.filename);
481+
setContentHash(agentId, section.filename, value);
482+
setLastUpdated(getSectionTimestamp(agentId, section.filename));
456483
savedTimerRef.current = setTimeout(() => setSaveState("idle"), 2000);
457484
},
458485
() => {
@@ -492,6 +519,9 @@ function BrainEditor({
492519
() => {
493520
setFileExists(true);
494521
setSaveState("saved");
522+
setSectionTimestamp(agentId, section.filename);
523+
setContentHash(agentId, section.filename, content);
524+
setLastUpdated(getSectionTimestamp(agentId, section.filename));
495525
savedTimerRef.current = setTimeout(() => setSaveState("idle"), 2000);
496526
},
497527
() => setSaveState("idle"),
@@ -544,7 +574,7 @@ function BrainEditor({
544574

545575
return (
546576
<div className="flex flex-1 flex-col overflow-y-auto">
547-
<SectionHeader section={section} saveState={saveState} fileExists={fileExists}>
577+
<SectionHeader section={section} saveState={saveState} fileExists={fileExists} lastUpdated={lastUpdated}>
548578
{section.id === "knowledge-base" ? (
549579
<ImportButton fileInputRef={fileInputRef} onImport={handleImport} onFileSelected={handleFileSelected} />
550580
) : null}
@@ -597,7 +627,7 @@ function BrainEditor({
597627

598628
return (
599629
<div className="flex flex-1 flex-col overflow-y-auto">
600-
<SectionHeader section={section} saveState={saveState} fileExists={fileExists}>
630+
<SectionHeader section={section} saveState={saveState} fileExists={fileExists} lastUpdated={lastUpdated}>
601631
{section.id === "knowledge" ? (
602632
<ImportButton fileInputRef={fileInputRef} onImport={handleImport} onFileSelected={handleFileSelected} />
603633
) : null}
@@ -1148,11 +1178,13 @@ function SectionHeader({
11481178
section,
11491179
saveState,
11501180
fileExists,
1181+
lastUpdated,
11511182
children,
11521183
}: {
11531184
section: BrainSection;
11541185
saveState: "idle" | "saving" | "saved";
11551186
fileExists: boolean;
1187+
lastUpdated?: string | null;
11561188
children?: React.ReactNode;
11571189
}) {
11581190
return (
@@ -1162,7 +1194,14 @@ function SectionHeader({
11621194
<section.icon className="size-3.5 text-primary" />
11631195
</div>
11641196
<div>
1165-
<h2 className="font-display text-sm font-bold tracking-tight">{section.label}</h2>
1197+
<div className="flex items-center gap-2">
1198+
<h2 className="font-display text-sm font-bold tracking-tight">{section.label}</h2>
1199+
{lastUpdated && (
1200+
<span className="text-[11px] font-mono text-muted-foreground/60 tabular-nums">
1201+
Updated {formatRelativeTime(lastUpdated)}
1202+
</span>
1203+
)}
1204+
</div>
11661205
<p className="text-[11px] text-muted-foreground/70">{section.description}</p>
11671206
</div>
11681207
</div>
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* Tracks last-updated timestamps for Brain page sections using localStorage.
3+
* Since the workspace file API doesn't include modification timestamps,
4+
* we record them on the frontend when files are written and detect
5+
* external changes (e.g. from Refine context) via content hashing.
6+
*/
7+
8+
const STORAGE_KEY = "opengoat:brain-section-timestamps";
9+
10+
interface SectionRecord {
11+
timestamp: string; // ISO date string
12+
contentHash: number; // djb2 hash of content
13+
}
14+
15+
type TimestampStore = Record<string, SectionRecord>;
16+
17+
function makeKey(agentId: string, filename: string): string {
18+
return `${agentId}:${filename}`;
19+
}
20+
21+
function djb2Hash(str: string): number {
22+
let hash = 5381;
23+
for (let i = 0; i < str.length; i++) {
24+
hash = (hash * 33) ^ str.charCodeAt(i);
25+
}
26+
return hash >>> 0; // unsigned 32-bit
27+
}
28+
29+
function readStore(): TimestampStore {
30+
try {
31+
const raw = localStorage.getItem(STORAGE_KEY);
32+
return raw ? (JSON.parse(raw) as TimestampStore) : {};
33+
} catch {
34+
return {};
35+
}
36+
}
37+
38+
function writeStore(store: TimestampStore): void {
39+
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
40+
}
41+
42+
/**
43+
* Get the last-updated timestamp for a brain section.
44+
* Returns an ISO date string or null if no timestamp has been recorded.
45+
*/
46+
export function getSectionTimestamp(agentId: string, filename: string): string | null {
47+
const store = readStore();
48+
return store[makeKey(agentId, filename)]?.timestamp ?? null;
49+
}
50+
51+
/**
52+
* Record the current time as the last-updated timestamp for a brain section.
53+
*/
54+
export function setSectionTimestamp(agentId: string, filename: string): void {
55+
const store = readStore();
56+
const key = makeKey(agentId, filename);
57+
const existing = store[key];
58+
store[key] = {
59+
timestamp: new Date().toISOString(),
60+
contentHash: existing?.contentHash ?? 0,
61+
};
62+
writeStore(store);
63+
}
64+
65+
/**
66+
* Store a content hash for detecting external modifications.
67+
*/
68+
export function setContentHash(agentId: string, filename: string, content: string): void {
69+
const store = readStore();
70+
const key = makeKey(agentId, filename);
71+
const existing = store[key];
72+
store[key] = {
73+
timestamp: existing?.timestamp ?? new Date().toISOString(),
74+
contentHash: djb2Hash(content),
75+
};
76+
writeStore(store);
77+
}
78+
79+
/**
80+
* Check if the current content differs from the last recorded hash.
81+
* Returns true if the content has changed or if no hash was previously stored.
82+
*/
83+
export function hasContentChanged(agentId: string, filename: string, content: string): boolean {
84+
const store = readStore();
85+
const record = store[makeKey(agentId, filename)];
86+
if (!record) return true;
87+
return record.contentHash !== djb2Hash(content);
88+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
2+
import { readFileSync } from "node:fs";
3+
import { resolve } from "node:path";
4+
5+
// ---------------------------------------------------------------------------
6+
// Unit tests for brain-section-timestamps utility
7+
// ---------------------------------------------------------------------------
8+
9+
describe("brain-section-timestamps utility", () => {
10+
let mod: typeof import("../../apps/desktop/src/features/brain/lib/brain-section-timestamps");
11+
12+
const STORAGE_KEY = "opengoat:brain-section-timestamps";
13+
14+
beforeEach(() => {
15+
// Mock localStorage
16+
const store: Record<string, string> = {};
17+
vi.stubGlobal("localStorage", {
18+
getItem: vi.fn((key: string) => store[key] ?? null),
19+
setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
20+
removeItem: vi.fn((key: string) => { delete store[key]; }),
21+
});
22+
});
23+
24+
afterEach(() => {
25+
vi.unstubAllGlobals();
26+
vi.resetModules();
27+
});
28+
29+
async function load() {
30+
mod = await import("../../apps/desktop/src/features/brain/lib/brain-section-timestamps");
31+
return mod;
32+
}
33+
34+
it("returns null when no timestamp has been stored", async () => {
35+
const { getSectionTimestamp } = await load();
36+
expect(getSectionTimestamp("agent-1", "PRODUCT.md")).toBeNull();
37+
});
38+
39+
it("stores and retrieves a timestamp", async () => {
40+
const { getSectionTimestamp, setSectionTimestamp } = await load();
41+
const before = Date.now();
42+
setSectionTimestamp("agent-1", "PRODUCT.md");
43+
const ts = getSectionTimestamp("agent-1", "PRODUCT.md");
44+
expect(ts).not.toBeNull();
45+
const parsed = new Date(ts!).getTime();
46+
expect(parsed).toBeGreaterThanOrEqual(before);
47+
expect(parsed).toBeLessThanOrEqual(Date.now());
48+
});
49+
50+
it("stores timestamps independently per agent and filename", async () => {
51+
const { getSectionTimestamp, setSectionTimestamp } = await load();
52+
setSectionTimestamp("agent-1", "PRODUCT.md");
53+
setSectionTimestamp("agent-2", "MARKET.md");
54+
expect(getSectionTimestamp("agent-1", "PRODUCT.md")).not.toBeNull();
55+
expect(getSectionTimestamp("agent-2", "MARKET.md")).not.toBeNull();
56+
expect(getSectionTimestamp("agent-1", "MARKET.md")).toBeNull();
57+
});
58+
59+
it("detects content changes via hash comparison", async () => {
60+
const { hasContentChanged, setContentHash } = await load();
61+
setContentHash("agent-1", "PRODUCT.md", "hello world");
62+
expect(hasContentChanged("agent-1", "PRODUCT.md", "hello world")).toBe(false);
63+
expect(hasContentChanged("agent-1", "PRODUCT.md", "updated content")).toBe(true);
64+
});
65+
66+
it("returns true for hasContentChanged when no hash stored", async () => {
67+
const { hasContentChanged } = await load();
68+
// No prior hash — treat as changed (new file)
69+
expect(hasContentChanged("agent-1", "PRODUCT.md", "some content")).toBe(true);
70+
});
71+
72+
it("persists data to localStorage under the correct key", async () => {
73+
const { setSectionTimestamp } = await load();
74+
setSectionTimestamp("agent-1", "PRODUCT.md");
75+
expect(localStorage.setItem).toHaveBeenCalledWith(
76+
STORAGE_KEY,
77+
expect.any(String),
78+
);
79+
});
80+
});
81+
82+
// ---------------------------------------------------------------------------
83+
// UI integration tests — SectionHeader shows timestamp
84+
// ---------------------------------------------------------------------------
85+
86+
const brainSrc = readFileSync(
87+
resolve(__dirname, "../../apps/desktop/src/features/brain/components/BrainWorkspace.tsx"),
88+
"utf-8",
89+
);
90+
91+
describe("Brain section freshness indicator — SectionHeader", () => {
92+
// AC1: SectionHeader accepts and renders a lastUpdated prop
93+
it("SectionHeader accepts a lastUpdated prop", () => {
94+
// The SectionHeader function signature should include lastUpdated
95+
expect(brainSrc).toMatch(/lastUpdated\??:\s*string\s*\|\s*null/);
96+
});
97+
98+
// AC2: Timestamp uses 11px mono muted styling consistent with chat timestamps
99+
it("uses the metadata timestamp styling class", () => {
100+
// Should have the exact metadata styling for the timestamp
101+
expect(brainSrc).toMatch(/text-\[11px\].*font-mono.*text-muted-foreground\/60/);
102+
expect(brainSrc).toMatch(/tabular-nums/);
103+
});
104+
105+
// AC3: Uses formatRelativeTime from the shared utility
106+
it("imports formatRelativeTime from the shared output-labels utility", () => {
107+
expect(brainSrc).toMatch(/import.*formatRelativeTime.*from.*output-labels/);
108+
});
109+
110+
// AC4: Timestamp only renders when lastUpdated is truthy
111+
it("conditionally renders timestamp only when lastUpdated is present", () => {
112+
expect(brainSrc).toMatch(/lastUpdated\s*[?&]/);
113+
});
114+
115+
// AC5: BrainEditor tracks timestamps via the utility
116+
it("imports brain-section-timestamps utility", () => {
117+
expect(brainSrc).toMatch(/from.*brain-section-timestamps/);
118+
});
119+
120+
// AC6: Timestamp is updated on successful save
121+
it("calls setSectionTimestamp on successful file write", () => {
122+
expect(brainSrc).toMatch(/setSectionTimestamp/);
123+
});
124+
125+
// AC7: Content hash is tracked for detecting external changes (Refine)
126+
it("tracks content hash for detecting external changes", () => {
127+
expect(brainSrc).toMatch(/setContentHash|hasContentChanged/);
128+
});
129+
});

0 commit comments

Comments
 (0)