Skip to content

Commit

Permalink
feat(@fluid-example/ai-collab): Add Undo / Redo to AI-Collab Example …
Browse files Browse the repository at this point in the history
…App (#23191)

#### Description


[25398](https://dev.azure.com/fluidframework/internal/_workitems/edit/25398/)

This PR adds revert functionality to the `@fluid-example/ai-collab`
package so that the users can freely undo / redo commits that have been
already merged to the SharedTree's main branch.

![2024-12-20 16 28
34](https://github.com/user-attachments/assets/2601682a-566a-4375-a76c-ab0ddcbcf5bb)

---------

Co-authored-by: Alex Villarreal <[email protected]>
  • Loading branch information
jikim-msft and alexvy86 authored Dec 30, 2024
1 parent cd34c30 commit 1319293
Show file tree
Hide file tree
Showing 3 changed files with 160 additions and 4 deletions.
1 change: 1 addition & 0 deletions examples/apps/ai-collab/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@iconify/react": "^5.0.2",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@microsoft/microsoft-graph-types": "^2.40.0",
"@mui/icons-material": "^6.2.1",
"@mui/lab": "6.0.0-beta.9",
"@mui/material": "^6.0.2",
"@types/react": "^18.3.11",
Expand Down
143 changes: 139 additions & 4 deletions examples/apps/ai-collab/src/components/TaskGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,35 @@ import {
type Difference,
SharedTreeBranchManager,
} from "@fluidframework/ai-collab/alpha";
import { TreeAlpha, type TreeBranch, type TreeViewAlpha } from "@fluidframework/tree/alpha";
import {
CommitKind,
RevertibleStatus,
TreeAlpha,
type CommitMetadata,
type Revertible,
type RevertibleFactory,
type TreeBranch,
type TreeViewAlpha,
} from "@fluidframework/tree/alpha";
import { Icon } from "@iconify/react";
import { RedoRounded, UndoRounded } from "@mui/icons-material";
import { LoadingButton } from "@mui/lab";
import {
Box,
Button,
Card,
Dialog,
Divider,
IconButton,
Popover,
Stack,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import { type TreeView } from "fluid-framework";
import { useSnackbar } from "notistack";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";

import { TaskCard } from "./TaskCard";

Expand Down Expand Up @@ -59,8 +71,99 @@ export function TaskGroup(props: {
newBranchTargetNode: SharedTreeTaskGroup;
}>();

const [undoStack, setUndoStack] = useState<Revertible[]>([]);
const [redoStack, setRedoStack] = useState<Revertible[]>([]);

useSharedTreeRerender({ sharedTreeNode: props.sharedTreeTaskGroup, logId: "TaskGroup" });

/**
* Create undo and redo stacks of {@link Revertible}.
*/
useEffect(() => {
function onRevertibleDisposed(disposed: Revertible): void {
const redoIndex = redoStack.indexOf(disposed);
if (redoIndex === -1) {
const undoIndex = undoStack.indexOf(disposed);
if (undoIndex !== -1) {
setUndoStack((currUndoStack) => {
const newUndoStack = currUndoStack.toSpliced(undoIndex, 1);
return newUndoStack;
});
}
} else {
setRedoStack((currRedostack) => {
const newRedoStack = currRedostack.toSpliced(redoIndex, 1);
return newRedoStack;
});
}
}

/**
* Instead of application developer manually managing the life cycle of the {@link Revertible} instances,
* example app stores up to `MAX_STACK_SIZE` number of {@link Revertible} instances in each of the undo and redo stacks.
* When the stack size exceeds `MAX_STACK_SIZE`, the oldest {@link Revertible} instance is disposed.
* @param stack - The stack that the {@link Revertible} instance is being added to.
*/
function trimStackToMaxSize(stack: Revertible[]): Revertible[] {
const MAX_STACK_SIZE = 50;

if (stack.length <= MAX_STACK_SIZE) {
return stack;
}

const itemsToRemove = stack.length - MAX_STACK_SIZE;
const itemsToDispose = stack.slice(0, itemsToRemove);

for (const revertible of itemsToDispose) {
if (revertible?.status !== RevertibleStatus.Disposed) {
revertible?.dispose();
}
}

return stack.slice(itemsToRemove);
}

/**
* Event handler that manages the undo/redo functionality for tree view commits.
*
* @param commit - Metadata about the commit being applied
* @param getRevertible - Optional factory function that creates a Revertible object
*
* This handler:
* 1. Creates a Revertible object when a commit is applied
* 2. Adds the Revertible to either the undo or redo stack based on the commit type
* 3. Maintains a maximum stack size (defined in `maintainStackSize` function)
*
* The Revertible objects allow operations to be undone/redone, with automatic cleanup
* handled by the onRevertibleDisposed callback.
*
* @returns An event listener cleanup function
*/
const unsubscribeFromCommitAppliedEvent = props.treeView.events.on(
"commitApplied",
(commit: CommitMetadata, getRevertible?: RevertibleFactory) => {
if (getRevertible !== undefined) {
const revertible = getRevertible(onRevertibleDisposed);
if (commit.kind === CommitKind.Undo) {
setRedoStack((prevRedoStack) => {
const newRedoStack = trimStackToMaxSize([...prevRedoStack, revertible]);
return newRedoStack;
});
} else {
setUndoStack((prevUndoStack) => {
const newUndoStack = trimStackToMaxSize([...prevUndoStack, revertible]);
return newUndoStack;
});
}
}
},
);

return () => {
unsubscribeFromCommitAppliedEvent();
};
}, [props.treeView.events, undoStack, redoStack]);

/**
* Helper function for ai collaboration which creates a new branch from the current {@link SharedTreeAppState}
* as well as find the matching {@link SharedTreeTaskGroup} intended to be worked on within the new branch.
Expand Down Expand Up @@ -242,14 +345,46 @@ export function TaskGroup(props: {
treeView={llmBranchData.aiCollabBranch}
differences={llmBranchData.differences}
newBranchTargetNode={llmBranchData.newBranchTargetNode}
></TaskGroupDiffModal>
/>
)}

{undoStack.length > 0 && (
<Tooltip title="Undo">
<IconButton
color="error"
onClick={() => {
// Getting the revertible before removing it from the undo stack allows the the item to remains in the stack if `revert()` fails.
const revertible = undoStack[undoStack.length - 1];
revertible?.revert();
undoStack.pop();
}}
>
<UndoRounded />
</IconButton>
</Tooltip>
)}

{redoStack.length > 0 && (
<Tooltip title="Redo">
<IconButton
color="info"
onClick={() => {
// Getting the revertible before removing it from the redo stack allows the the item to remains in the stack if `revert()` fails.
const revertible = redoStack[redoStack.length - 1];
revertible?.revert();
redoStack.pop();
}}
>
<RedoRounded />
</IconButton>
</Tooltip>
)}

<Button
variant="contained"
color="success"
onClick={() => {
props.sharedTreeTaskGroup.tasks.insertAtStart({
props.sharedTreeTaskGroup.tasks.insertAtEnd({
title: `New Task #${props.sharedTreeTaskGroup.tasks.length + 1}`,
description: "This is the new task. ",
priority: "low",
Expand Down
20 changes: 20 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 1319293

Please sign in to comment.