Skip to content

Commit efa82f3

Browse files
committed
feat: add studio session history persistence
1 parent 1546b4b commit efa82f3

8 files changed

Lines changed: 404 additions & 27 deletions

File tree

frontend/src/studio/PlotStudioShell.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { PlotPreviewPanel } from './plot/PlotPreviewPanel'
66
import { useI18n } from '../i18n'
77
import ManimCatLogo from '../components/ManimCatLogo'
88
import { useModalTransition } from '../hooks/useModalTransition'
9+
import { StudioSessionHistoryModal } from './components/StudioSessionHistoryModal'
910

1011
interface PlotStudioShellProps {
1112
onExit: () => void
@@ -164,6 +165,7 @@ export function PlotStudioShell({ onExit, isExiting }: PlotStudioShellProps) {
164165
</div>
165166

166167
<StudioPermissionModeModal {...studio.permissionModeModal} />
168+
<StudioSessionHistoryModal {...studio.historyModal} />
167169
<StudioExitConfirmModal
168170
isOpen={confirmExitOpen}
169171
onClose={() => setConfirmExitOpen(false)}

frontend/src/studio/StudioShell.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { StudioPermissionModeModal } from './controls/StudioPermissionModeModal'
33
import { StudioAssetsPanel } from './components/StudioAssetsPanel'
44
import { StudioCommandPanel } from './components/StudioCommandPanel'
55
import { StudioPipelinePanel } from './components/StudioPipelinePanel'
6+
import { StudioSessionHistoryModal } from './components/StudioSessionHistoryModal'
67
import { useStudioReview } from './hooks/use-studio-review'
78
import { useStudioSession } from './hooks/use-studio-session'
89
import type { StudioKind } from './protocol/studio-agent-types'
@@ -79,6 +80,7 @@ export function StudioShell({ onExit, isExiting, studioKind = 'manim' }: StudioS
7980
</div>
8081

8182
<StudioPermissionModeModal {...studio.permissionModeModal} />
83+
<StudioSessionHistoryModal {...studio.historyModal} />
8284
</>
8385
)
84-
}
86+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { useModalTransition } from '../../hooks/useModalTransition'
2+
import { useI18n } from '../../i18n'
3+
import { formatStudioTime, truncateStudioText } from '../theme'
4+
import type { StudioSessionHistoryEntry } from '../hooks/use-studio-session'
5+
6+
interface StudioSessionHistoryModalProps {
7+
isOpen: boolean
8+
isLoading: boolean
9+
entries: StudioSessionHistoryEntry[]
10+
currentSessionId: string | null
11+
onClose: () => void
12+
onSelectSession: (sessionId: string) => Promise<void> | void
13+
}
14+
15+
export function StudioSessionHistoryModal({
16+
isOpen,
17+
isLoading,
18+
entries,
19+
currentSessionId,
20+
onClose,
21+
onSelectSession,
22+
}: StudioSessionHistoryModalProps) {
23+
const { t } = useI18n()
24+
const { shouldRender, isExiting } = useModalTransition(isOpen)
25+
26+
if (!shouldRender) {
27+
return null
28+
}
29+
30+
return (
31+
<div className="fixed inset-0 z-[150] flex items-center justify-center p-4">
32+
<div
33+
className={`absolute inset-0 bg-bg-primary/60 backdrop-blur-md transition-opacity duration-300 ${
34+
isExiting ? 'opacity-0' : 'animate-overlay-wash-in'
35+
}`}
36+
onClick={onClose}
37+
/>
38+
39+
<section
40+
className={`relative flex max-h-[min(80vh,52rem)] w-full max-w-2xl flex-col overflow-hidden rounded-[2.2rem] border border-border/10 bg-bg-secondary shadow-2xl ${
41+
isExiting ? 'animate-fade-out-soft' : 'animate-fade-in-soft'
42+
}`}
43+
>
44+
<header className="flex items-start justify-between gap-4 border-b border-border/8 px-8 py-7">
45+
<div>
46+
<div className="font-mono text-[10px] uppercase tracking-[0.38em] text-text-secondary/45">History</div>
47+
<h2 className="mt-3 text-xl font-medium tracking-tight text-text-primary">
48+
{t('studio.sessionLabel')}
49+
</h2>
50+
<p className="mt-2 text-sm leading-7 text-text-secondary/68">
51+
`/history` opens this list. `/new` starts a fresh studio session.
52+
</p>
53+
</div>
54+
<button
55+
type="button"
56+
onClick={onClose}
57+
className="rounded-2xl p-2.5 text-text-secondary/50 transition-all hover:bg-bg-primary/50 hover:text-text-primary"
58+
aria-label={t('common.close')}
59+
title={t('common.close')}
60+
>
61+
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
62+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
63+
</svg>
64+
</button>
65+
</header>
66+
67+
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5 sm:px-6">
68+
{isLoading && (
69+
<div className="flex h-40 items-center justify-center text-sm text-text-secondary/65">
70+
Loading sessions...
71+
</div>
72+
)}
73+
74+
{!isLoading && entries.length === 0 && (
75+
<div className="flex h-40 items-center justify-center text-center text-sm text-text-secondary/65">
76+
No recent sessions on this device yet.
77+
</div>
78+
)}
79+
80+
{!isLoading && entries.length > 0 && (
81+
<div className="space-y-3">
82+
{entries.map((entry) => {
83+
const isCurrent = entry.id === currentSessionId
84+
return (
85+
<button
86+
key={entry.id}
87+
type="button"
88+
onClick={() => void onSelectSession(entry.id)}
89+
className={`w-full rounded-[1.6rem] border px-5 py-4 text-left transition-all ${
90+
isCurrent
91+
? 'border-black/10 bg-bg-primary/72 dark:border-white/10 dark:bg-bg-primary/45'
92+
: 'border-transparent bg-bg-primary/35 hover:border-border/10 hover:bg-bg-primary/58'
93+
}`}
94+
>
95+
<div className="flex items-start justify-between gap-4">
96+
<div className="min-w-0">
97+
<div className="truncate text-sm font-medium text-text-primary">{entry.title}</div>
98+
<div className="mt-1 font-mono text-[10px] uppercase tracking-[0.26em] text-text-secondary/42">
99+
{entry.studioKind} · {formatStudioTime(entry.updatedAt)}
100+
</div>
101+
</div>
102+
{isCurrent && (
103+
<span className="shrink-0 rounded-full bg-accent/10 px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.18em] text-accent">
104+
Current
105+
</span>
106+
)}
107+
</div>
108+
<p className="mt-3 text-sm leading-6 text-text-secondary/72">
109+
{truncateStudioText(entry.previewText, 140)}
110+
</p>
111+
</button>
112+
)
113+
})}
114+
</div>
115+
)}
116+
</div>
117+
</section>
118+
</div>
119+
)
120+
}

frontend/src/studio/controls/command-parser.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,17 @@ export type StudioSlashCommand = {
55
type: 'permission-mode'
66
raw: '/safe' | '/auto' | '/full'
77
mode: StudioPermissionMode
8+
} | {
9+
type: 'history'
10+
raw: '/history'
11+
} | {
12+
type: 'new-session'
13+
raw: '/new'
814
}
915

1016
export function parseStudioSlashCommand(input: string): StudioSlashCommand | null {
17+
const normalized = input.trim().toLowerCase()
18+
1119
const permissionMode = parseStudioPermissionModeCommand(input)
1220
if (permissionMode) {
1321
return {
@@ -17,5 +25,19 @@ export function parseStudioSlashCommand(input: string): StudioSlashCommand | nul
1725
}
1826
}
1927

28+
if (normalized === '/history') {
29+
return {
30+
type: 'history',
31+
raw: '/history',
32+
}
33+
}
34+
35+
if (normalized === '/new') {
36+
return {
37+
type: 'new-session',
38+
raw: '/new',
39+
}
40+
}
41+
2042
return null
21-
}
43+
}

frontend/src/studio/controls/use-studio-controls.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,17 @@ interface UseStudioControlsInput {
77
session: StudioSession | null
88
onRun: (inputText: string) => Promise<void>
99
onSessionUpdated: (session: StudioSession) => Promise<void>
10+
onOpenHistory: () => void
11+
onCreateSession: () => Promise<void>
1012
}
1113

12-
export function useStudioControls({ session, onRun, onSessionUpdated }: UseStudioControlsInput) {
14+
export function useStudioControls({
15+
session,
16+
onRun,
17+
onSessionUpdated,
18+
onOpenHistory,
19+
onCreateSession,
20+
}: UseStudioControlsInput) {
1321
const [pendingMode, setPendingMode] = useState<StudioPermissionMode | null>(null)
1422
const [isApplyingMode, setIsApplyingMode] = useState(false)
1523

@@ -25,9 +33,19 @@ export function useStudioControls({ session, onRun, onSessionUpdated }: UseStudi
2533
return { kind: 'control' as const }
2634
}
2735

36+
if (command.type === 'history') {
37+
onOpenHistory()
38+
return { kind: 'control' as const }
39+
}
40+
41+
if (command.type === 'new-session') {
42+
await onCreateSession()
43+
return { kind: 'control' as const }
44+
}
45+
2846
await onRun(inputText)
2947
return { kind: 'run' as const }
30-
}, [onRun])
48+
}, [onCreateSession, onOpenHistory, onRun])
3149

3250
const closePermissionModeModal = useCallback(() => {
3351
if (isApplyingMode) {

frontend/src/studio/hooks/use-studio-run.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -58,18 +58,21 @@ export function useStudioRun({ session, onOptimisticMessagesCreated, onRunSubmit
5858
inputText,
5959
}))
6060

61-
const pendingPermissions = filterPermissionsForSession(response.pendingPermissions, activeSession.id)
62-
63-
onRunStarted(response.run, pendingPermissions)
64-
onSnapshotLoaded({
65-
session: activeSession,
66-
messages: response.messages,
67-
runs: response.runs,
68-
tasks: response.tasks,
69-
works: response.works,
70-
workResults: response.workResults,
71-
}, pendingPermissions)
72-
} catch (error) {
61+
const pendingPermissions = filterPermissionsForSession(response.pendingPermissions, activeSession.id)
62+
const snapshotRuns = response.runs.some((run) => run.id === response.run.id)
63+
? response.runs
64+
: [...response.runs, response.run]
65+
66+
onRunStarted(response.run, pendingPermissions)
67+
onSnapshotLoaded({
68+
session: activeSession,
69+
messages: response.messages,
70+
runs: snapshotRuns,
71+
tasks: response.tasks,
72+
works: response.works,
73+
workResults: response.workResults,
74+
}, pendingPermissions)
75+
} catch (error) {
7376
if (
7477
allowRecovery
7578
&& error instanceof StudioApiRequestError

0 commit comments

Comments
 (0)