-
-
Hyper AI Workspace
-
+
+
+
+ Hyper AI Workspace
+
+ {sessionSaved && isTopicContextReady && (
+
+
+ Saved on this device
+
+ )}
+
+
{isFollowupMode
? topicTitle
: `Ask questions about ${subjectCode.toUpperCase()}`}
@@ -363,6 +505,15 @@ export default function WorkspaceChat({
transition={{ duration: 0.35, ease: "easeOut" }}
/>
+ {lastStudied && (
+
+ Last studied{" "}
+
+ {lastStudied.prefix}
+ {lastStudied.detail ? ` ยท ${lastStudied.detail}` : ""}
+
+
+ )}
)}
@@ -404,6 +555,29 @@ export default function WorkspaceChat({
) : (
<>
+ {showContinueLearning && lastStudiedAt && hasConversation && (
+
+
+ Continue Learning
+
+
+ Last opened {formatContinueLearningLabel(lastStudiedAt)}.
+
+
+
+ )}
+
{isTopicContextReady && explanationCached !== undefined && (
{explanationLoading
@@ -454,6 +628,20 @@ export default function WorkspaceChat({
)}
+ {isFollowupMode &&
+ !hasConversation &&
+ !explanationLoading &&
+ !limitReached && (
+
+
+ Start asking questions about this topic.
+
+
+ Your progress will be remembered on this device.
+
+
+ )}
+
{isFollowupMode &&
!limitReached &&
!explanationLoading &&
diff --git a/lib/ai/session-service.ts b/lib/ai/session-service.ts
new file mode 100644
index 0000000..d7ab20d
--- /dev/null
+++ b/lib/ai/session-service.ts
@@ -0,0 +1,262 @@
+import type {
+ ChatMessage,
+ LearningSession,
+ StoredChatMessage,
+} from "@/types/ai";
+
+export const SESSION_KEY_PREFIX = "hyper-ai:";
+export const SESSION_INDEX_KEY = "hyper-ai:__index__";
+export const SESSION_MAX_COUNT = 100;
+export const SESSION_EXPIRY_DAYS = 30;
+
+interface SessionIndexEntry {
+ topicId: string;
+ updatedAt: string;
+}
+
+function isBrowser(): boolean {
+ return typeof window !== "undefined" && typeof localStorage !== "undefined";
+}
+
+export function buildSessionKey(topicId: string): string {
+ return `${SESSION_KEY_PREFIX}${topicId}`;
+}
+
+function readIndex(): SessionIndexEntry[] {
+ if (!isBrowser()) return [];
+
+ try {
+ const raw = localStorage.getItem(SESSION_INDEX_KEY);
+ if (!raw) return [];
+
+ const parsed = JSON.parse(raw) as SessionIndexEntry[];
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
+
+function writeIndex(entries: SessionIndexEntry[]): void {
+ if (!isBrowser()) return;
+ localStorage.setItem(SESSION_INDEX_KEY, JSON.stringify(entries));
+}
+
+function removeSessionKey(topicId: string): void {
+ if (!isBrowser()) return;
+ localStorage.removeItem(buildSessionKey(topicId));
+}
+
+function enforceMaxSessions(index: SessionIndexEntry[]): SessionIndexEntry[] {
+ const sorted = [...index].sort(
+ (a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
+ );
+
+ while (sorted.length > SESSION_MAX_COUNT) {
+ const oldest = sorted.shift();
+ if (oldest) {
+ removeSessionKey(oldest.topicId);
+ }
+ }
+
+ return sorted;
+}
+
+export function cleanupExpiredSessions(): void {
+ if (!isBrowser()) return;
+
+ const cutoff = Date.now() - SESSION_EXPIRY_DAYS * 24 * 60 * 60 * 1000;
+ const index = readIndex();
+ const kept: SessionIndexEntry[] = [];
+
+ for (const entry of index) {
+ if (new Date(entry.updatedAt).getTime() < cutoff) {
+ removeSessionKey(entry.topicId);
+ } else {
+ kept.push(entry);
+ }
+ }
+
+ writeIndex(kept);
+}
+
+export function loadSession(topicId: string): LearningSession | null {
+ if (!isBrowser() || !topicId) return null;
+
+ cleanupExpiredSessions();
+
+ try {
+ const raw = localStorage.getItem(buildSessionKey(topicId));
+ if (!raw) return null;
+
+ const session = JSON.parse(raw) as LearningSession;
+
+ if (
+ !session?.topicId ||
+ !session.cachedExplanation ||
+ !Array.isArray(session.messages)
+ ) {
+ return null;
+ }
+
+ const cutoff = Date.now() - SESSION_EXPIRY_DAYS * 24 * 60 * 60 * 1000;
+ if (new Date(session.updatedAt).getTime() < cutoff) {
+ deleteSession(topicId);
+ return null;
+ }
+
+ return session;
+ } catch {
+ return null;
+ }
+}
+
+export function saveSession(session: LearningSession): void {
+ if (!isBrowser() || !session.topicId) return;
+
+ cleanupExpiredSessions();
+
+ const updatedAt = session.updatedAt || new Date().toISOString();
+ const payload: LearningSession = {
+ ...session,
+ updatedAt,
+ questionCount: session.messages.filter((m) => m.role === "user").length,
+ };
+
+ localStorage.setItem(
+ buildSessionKey(session.topicId),
+ JSON.stringify(payload)
+ );
+
+ const index = readIndex().filter(
+ (entry) => entry.topicId !== session.topicId
+ );
+ index.push({ topicId: session.topicId, updatedAt });
+ writeIndex(enforceMaxSessions(index));
+}
+
+export function deleteSession(topicId: string): void {
+ if (!isBrowser() || !topicId) return;
+
+ removeSessionKey(topicId);
+ writeIndex(readIndex().filter((entry) => entry.topicId !== topicId));
+}
+
+export function sessionMatchesContext(
+ session: LearningSession,
+ context: {
+ branch: string;
+ semester: string;
+ subjectCode: string;
+ }
+): boolean {
+ return (
+ session.branch === context.branch &&
+ session.semester === context.semester &&
+ session.subjectCode === context.subjectCode
+ );
+}
+
+export function toStoredMessages(
+ messages: Array<{
+ id: string;
+ role: "user" | "assistant";
+ content: string;
+ timestamp: Date;
+ }>
+): StoredChatMessage[] {
+ return messages.map((message) => ({
+ id: message.id,
+ role: message.role,
+ content: message.content,
+ timestamp: message.timestamp.toISOString(),
+ }));
+}
+
+export function fromStoredMessages(messages: StoredChatMessage[]): Array<{
+ id: string;
+ role: "user" | "assistant";
+ content: string;
+ timestamp: Date;
+}> {
+ return messages.map((message) => ({
+ id: message.id,
+ role: message.role,
+ content: message.content,
+ timestamp: new Date(message.timestamp),
+ }));
+}
+
+export function toChatMessages(messages: StoredChatMessage[]): ChatMessage[] {
+ return messages.map((message) => ({
+ role: message.role,
+ content: message.content,
+ }));
+}
+
+export interface LastStudiedLabel {
+ prefix: string;
+ detail: string;
+}
+
+export function formatLastStudied(updatedAt: string): LastStudiedLabel {
+ const date = new Date(updatedAt);
+ const now = new Date();
+
+ const startOfToday = new Date(
+ now.getFullYear(),
+ now.getMonth(),
+ now.getDate()
+ );
+ const startOfDate = new Date(
+ date.getFullYear(),
+ date.getMonth(),
+ date.getDate()
+ );
+
+ const dayDiff = Math.round(
+ (startOfToday.getTime() - startOfDate.getTime()) / (1000 * 60 * 60 * 24)
+ );
+
+ const timeDetail = date.toLocaleTimeString(undefined, {
+ hour: "numeric",
+ minute: "2-digit",
+ });
+
+ if (dayDiff === 0) {
+ return { prefix: "Today", detail: timeDetail };
+ }
+
+ if (dayDiff === 1) {
+ return { prefix: "Yesterday", detail: timeDetail };
+ }
+
+ if (dayDiff < 14) {
+ return { prefix: `${dayDiff} days ago`, detail: "" };
+ }
+
+ const weeks = Math.floor(dayDiff / 7);
+ if (weeks < 8) {
+ return {
+ prefix: `${weeks} week${weeks === 1 ? "" : "s"} ago`,
+ detail: "",
+ };
+ }
+
+ return {
+ prefix: date.toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ }),
+ detail: "",
+ };
+}
+
+export function formatContinueLearningLabel(updatedAt: string): string {
+ const { prefix } = formatLastStudied(updatedAt);
+
+ if (prefix === "Today") return "earlier today";
+ if (prefix === "Yesterday") return "yesterday";
+
+ return prefix.toLowerCase();
+}
diff --git a/types/ai.ts b/types/ai.ts
index 5f88aa8..3cbc56f 100644
--- a/types/ai.ts
+++ b/types/ai.ts
@@ -140,3 +140,25 @@ export interface FollowupResponse {
answer?: string;
error?: string;
}
+
+// =========================
+// Local Learning Session
+// =========================
+
+export interface StoredChatMessage {
+ id: string;
+ role: "user" | "assistant";
+ content: string;
+ timestamp: string;
+}
+
+export interface LearningSession {
+ topicId: string;
+ branch: string;
+ semester: string;
+ subjectCode: string;
+ cachedExplanation: string;
+ messages: StoredChatMessage[];
+ questionCount: number;
+ updatedAt: string;
+}