Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 112 additions & 1 deletion cli/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useShallow } from 'zustand/react/shallow'

import { Chat } from './chat'
import { ChatHistoryScreen } from './components/chat-history-screen'
import { UndoHistoryScreen } from './components/undo-history-screen'
import { ChatRuntimeProvider } from './contexts/chat-runtime-context'
import { FreebuffSupersededScreen } from './components/freebuff-superseded-screen'
import { LoginModal } from './components/login-modal'
Expand All @@ -13,10 +14,13 @@ import { useAuthQuery } from './hooks/use-auth-query'
import { useAuthState } from './hooks/use-auth-state'
import { useFreebuffSession } from './hooks/use-freebuff-session'
import { useTerminalFocus } from './hooks/use-terminal-focus'
import { getProjectRoot, startNewChat } from './project-files'
import { getCurrentChatId, getProjectRoot, startNewChat } from './project-files'
import { useChatHistoryStore } from './state/chat-history-store'
import { useUndoHistoryStore } from './state/undo-history-store'
import { redoToRecord, undoToRecord } from './state/undo-store'
import { stopActiveRun } from './utils/active-run'
import { useChatStore } from './state/chat-store'
import { getSystemMessage } from './utils/message-history'
import type { TopBannerType } from './types/store'
import { IS_FREEBUFF } from './utils/constants'
import { findGitRoot } from './utils/git'
Expand Down Expand Up @@ -160,6 +164,13 @@ export const App = ({
// Chat history state from store
const { showChatHistory, closeChatHistory } = useChatHistoryStore()

const {
showUndoHistory,
closeUndoHistory,
showRedoHistory,
closeRedoHistory,
} = useUndoHistoryStore()

// State to track which chat to resume (set when user selects from history)
const [resumeChatId, setResumeChatId] = useState<string | null>(null)

Expand Down Expand Up @@ -187,6 +198,68 @@ export const App = ({
setResumeChatId(null)
}, [closeChatHistory, resetChatStore])

// Undo/redo pickers: revert (or restore) the selected record against the
// project, then surface the result as a system message in the current chat.
const handleUndoSelect = useCallback(
async (recordId: string) => {
stopActiveRun('undo-history')
closeUndoHistory()
try {
const message =
(await undoToRecord(getCurrentChatId(), projectRoot, recordId)) ??
'Could not undo — the snapshot store is unavailable.'
useChatStore.getState().setMessages((prev) => [
...prev,
getSystemMessage(message, { commandResult: 'undo' }),
])
} catch (error) {
useChatStore.getState().setMessages((prev) => [
...prev,
getSystemMessage('Could not undo the selected change.', {
commandResult: 'undo',
}),
])
}
setInputFocused(true)
},
[closeUndoHistory, projectRoot, setInputFocused],
)

const handleRedoSelect = useCallback(
async (recordId: string) => {
stopActiveRun('redo-history')
closeRedoHistory()
try {
const message =
(await redoToRecord(getCurrentChatId(), projectRoot, recordId)) ??
'Could not redo — the snapshot store is unavailable.'
useChatStore.getState().setMessages((prev) => [
...prev,
getSystemMessage(message, { commandResult: 'redo' }),
])
} catch (error) {
useChatStore.getState().setMessages((prev) => [
...prev,
getSystemMessage('Could not redo the selected change.', {
commandResult: 'redo',
}),
])
}
setInputFocused(true)
},
[closeRedoHistory, projectRoot, setInputFocused],
)

const handleCancelUndoHistory = useCallback(() => {
closeUndoHistory()
setInputFocused(true)
}, [closeUndoHistory, setInputFocused])

const handleCancelRedoHistory = useCallback(() => {
closeRedoHistory()
setInputFocused(true)
}, [closeRedoHistory, setInputFocused])

// Determine effective continueChat values
const effectiveContinueChat = continueChat || resumeChatId !== null
const effectiveContinueChatId = resumeChatId ?? continueChatId
Expand Down Expand Up @@ -260,6 +333,12 @@ export const App = ({
onSelectChat={handleResumeChat}
onCancelChatHistory={closeChatHistory}
onNewChat={handleNewChat}
showUndoHistory={showUndoHistory}
onUndoSelect={handleUndoSelect}
onCancelUndoHistory={handleCancelUndoHistory}
showRedoHistory={showRedoHistory}
onRedoSelect={handleRedoSelect}
onCancelRedoHistory={handleCancelRedoHistory}
/>
)
}
Expand All @@ -285,6 +364,12 @@ interface AuthedSurfaceProps {
onSelectChat: (chatId: string) => void
onCancelChatHistory: () => void
onNewChat: () => void
showUndoHistory: boolean
onUndoSelect: (recordId: string) => void
onCancelUndoHistory: () => void
showRedoHistory: boolean
onRedoSelect: (recordId: string) => void
onCancelRedoHistory: () => void
}

/**
Expand Down Expand Up @@ -327,6 +412,12 @@ const AuthedSurfaceRoutes = ({
onSelectChat,
onCancelChatHistory,
onNewChat,
showUndoHistory,
onUndoSelect,
onCancelUndoHistory,
showRedoHistory,
onRedoSelect,
onCancelRedoHistory,
session,
sessionError,
}: AuthedSurfaceProps & {
Expand Down Expand Up @@ -381,6 +472,26 @@ const AuthedSurfaceRoutes = ({
)
}

if (showUndoHistory) {
return (
<UndoHistoryScreen
mode="undo"
onSelect={onUndoSelect}
onCancel={onCancelUndoHistory}
/>
)
}

if (showRedoHistory) {
return (
<UndoHistoryScreen
mode="redo"
onSelect={onRedoSelect}
onCancel={onCancelRedoHistory}
/>
)
}

return (
<Chat
consumeInitialPrompt={consumeInitialPrompt}
Expand Down
9 changes: 9 additions & 0 deletions cli/src/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { WEBSITE_URL } from './login/constants'
import { getProjectRoot } from './project-files'
import { useChatHistoryStore } from './state/chat-history-store'
import { useChatStore } from './state/chat-store'
import { useUndoHistoryStore } from './state/undo-history-store'
import { useReviewStore } from './state/review-store'
import { useFeedbackStore } from './state/feedback-store'
import { useMessageBlockStore } from './state/message-block-store'
Expand Down Expand Up @@ -762,6 +763,14 @@ export const Chat = ({
useChatHistoryStore.getState().openChatHistory()
}

if (result.openUndoHistory) {
useUndoHistoryStore.getState().openUndoHistory()
}

if (result.openRedoHistory) {
useUndoHistoryStore.getState().openRedoHistory()
}

if (result.openReviewScreen) {
useReviewStore.getState().openReviewScreen()
}
Expand Down
104 changes: 103 additions & 1 deletion cli/src/commands/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,19 @@ import { handleUsageCommand } from './usage'
import { returnToFreebuffLanding } from '../hooks/use-freebuff-session'
import { useThemeStore } from '../hooks/use-theme'
import { WEBSITE_URL } from '../login/constants'
import { startNewChat } from '../project-files'
import { getCurrentChatId, startNewChat, tryGetProjectRoot } from '../project-files'
import { useChatStore } from '../state/chat-store'
import { listRedoEntries, listUndoEntries } from '../state/undo-store'
import { stopActiveRun } from '../utils/active-run'
import { useFeedbackStore } from '../state/feedback-store'
import { useLoginStore } from '../state/login-store'
import { AGENT_MODES, END_SESSION_MESSAGE, IS_FREEBUFF } from '../utils/constants'
import { exitCliCleanly } from '../utils/exit-cleanly'
import { getSystemMessage, getUserMessage } from '../utils/message-history'
import { capturePendingAttachments } from '../utils/pending-attachments'
import { isUndoEnabled } from '../utils/settings'
import { getSkillByName } from '../utils/skill-registry'
import { isUndoAvailable } from '../utils/undo-snapshot'

import type { MultilineInputHandle } from '../components/multiline-input'
import type { InputValue, PendingAttachment } from '../types/store'
Expand Down Expand Up @@ -63,6 +66,8 @@ export type CommandResult = {
openFeedbackMode?: boolean
openPublishMode?: boolean
openChatHistory?: boolean
openUndoHistory?: boolean
openRedoHistory?: boolean
openReviewScreen?: boolean
preSelectAgents?: string[]
} | void
Expand Down Expand Up @@ -489,6 +494,103 @@ const ALL_COMMANDS: CommandDefinition[] = [
return { openChatHistory: true }
},
}),
defineCommand({
name: 'undo',
aliases: ['u'],
handler: (params) => {
const { streamingAgents, isChainInProgress } = useChatStore.getState()
if (streamingAgents.size > 0 || isChainInProgress) {
params.setMessages((prev) => [
...prev,
getSystemMessage(
'⏳ Please wait for the current task to finish before undoing.',
),
])
return
}
if (!isUndoEnabled()) {
params.setMessages((prev) => [
...prev,
getSystemMessage('Undo is disabled in settings.'),
])
return
}
const projectRoot = tryGetProjectRoot()
if (!projectRoot) {
params.setMessages((prev) => [
...prev,
getSystemMessage('No project is open to undo.'),
])
return
}
if (!isUndoAvailable(projectRoot)) {
params.setMessages((prev) => [
...prev,
getSystemMessage(
'Undo requires the project to be a git repository.',
),
])
return
}
if (listUndoEntries(getCurrentChatId()).length === 0) {
params.setMessages((prev) => [
...prev,
getSystemMessage('Nothing to undo.'),
])
return
}

// Always open the picker: the user chooses which recorded turn to
// revert (Enter reverts that turn and everything newer).
params.saveToHistory(params.inputValue.trim())
clearInput(params)
return { openUndoHistory: true }
},
}),
defineCommand({
name: 'redo',
aliases: ['r'],
handler: (params) => {
const { streamingAgents, isChainInProgress } = useChatStore.getState()
if (streamingAgents.size > 0 || isChainInProgress) {
params.setMessages((prev) => [
...prev,
getSystemMessage(
'⏳ Please wait for the current task to finish before redoing.',
),
])
return
}
if (!isUndoEnabled()) {
params.setMessages((prev) => [
...prev,
getSystemMessage('Redo is disabled in settings.'),
])
return
}
const projectRoot = tryGetProjectRoot()
if (!projectRoot) {
params.setMessages((prev) => [
...prev,
getSystemMessage('No project is open to redo.'),
])
return
}
if (listRedoEntries(getCurrentChatId()).length === 0) {
params.setMessages((prev) => [
...prev,
getSystemMessage('Nothing to redo.'),
])
return
}

// Always open the picker: the user chooses which undone change to
// restore.
params.saveToHistory(params.inputValue.trim())
clearInput(params)
return { openRedoHistory: true }
},
}),
defineCommandWithArgs({
name: 'interview',
handler: (params, args) => {
Expand Down
Loading